Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,20 @@ let avg_response_time = metrics.avg(
println!("Average response time: {:?}", avg_response_time);
```

`Sum` returns an `f64` for both counters and gauges. To get the exact `u64` total of a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor][F1] Pre-existing, outside the diff: the Type System block at README.md:122 contradicts this sentence.

let counter_sum: Option<u64> = counter_collection.sum(&name, &labels); matches no method at the merged head: the only (&MetricName, &LabelSet) aggregate on MetricKindCollection<Counter> is Sum, which returns Option<f64> (src/metric_collection/aggregate/sum.rs:67-73), and the only one returning Option<u64> is the new sum_counter. With this line now stating that Sum returns an f64 for counters, the README says both. A follow-up can rewrite line 122 as metric_collection.sum_counter(&name, &labels).

counter (no rounding above `2^53`), use `SumCounter`:

```rust
use torrust_metrics::metric_collection::aggregate::sum::SumCounter;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor][F2] Pre-existing, outside the diff: this import path is right, but the one two blocks up is not.

README.md:72 imports torrust_metrics::metric_collection::aggregate::{Sum, Avg}, while src/metric_collection/aggregate/mod.rs declares only pub mod avg; and pub mod sum; with no re-exports, so that path does not resolve; the traits live at aggregate::sum::Sum and aggregate::avg::Avg. A reader who copies the aggregation section gets one working import and one failing one. The README blocks are compiled nowhere (src/lib.rs does not include the README as crate docs), which is how this went unnoticed. A follow-up can fix line 72, or add pub use re-exports in aggregate/mod.rs, which would also make aggregate::SumCounter valid.


let total_requests: Option<u64> = metrics.sum_counter(
&metric_name!("requests_total"),
&[("server", "tracker-01")].into(),
);
```

It returns `None` if the metric does not exist or is not a counter.

## Architecture

### Core Components
Expand Down
4 changes: 2 additions & 2 deletions src/label/set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -576,8 +576,8 @@ mod tests {
for (name, value) in label_set.iter() {
count += 1;
// Verify we can access name and value
assert!(!name.to_string().is_empty());
assert!(!value.to_string().is_empty());
assert_ne!(name.to_string(), "");
assert_ne!(value.to_string(), "");
}

assert_eq!(count, 2);
Expand Down
118 changes: 118 additions & 0 deletions src/metric_collection/aggregate/sum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,47 @@ pub trait Sum {
fn sum(&self, metric_name: &MetricName, label_set_criteria: &LabelSet) -> Option<f64>;
}

/// Exact integer sum of a counter's samples.
///
/// Unlike [`Sum`], which returns an `f64` for both counters and gauges, this
/// trait returns the exact `u64` total, so values above `2^53` are not rounded.
///
/// It returns `None` if there is no counter with the given name. A metric with

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion][F3] Pin the Some(0) boundary next to the None cases.

Both None cases are documented and tested, but the neighbouring case is not: a counter that exists with no sample matching the criterion, or one registered through describe_counter (src/metric_collection/mod.rs:87-93) with no samples, returns Some(0) from Metric<Counter>::sum over an empty filter, not None. That None versus Some(0) line is what a consumer replacing its own integer-total helper relies on. A sentence here, one in the README, and a test such as returning_zero_when_no_sample_matches_the_label_set would settle it.

/// that name that is a gauge is not a counter, so it also returns `None`.
///
/// The addition is not checked: it panics on overflow in debug builds and
/// wraps in release builds, like [`Metric<Counter>::sum`](MetricSumTrait::sum).
///
/// # Example
///
/// ```
/// use torrust_clock::DurationSinceUnixEpoch;
/// use torrust_metrics::label::LabelSet;
/// use torrust_metrics::metric_collection::MetricCollection;
/// use torrust_metrics::metric_collection::aggregate::sum::SumCounter;
/// use torrust_metrics::metric_name;
///
/// let mut collection = MetricCollection::default();
/// let name = metric_name!("requests_total");
/// let time = DurationSinceUnixEpoch::from_secs(1);
///
/// collection.set_counter(&name, &LabelSet::empty(), (1 << 53) + 1, time).unwrap();
///
/// assert_eq!(collection.sum_counter(&name, &LabelSet::empty()), Some((1 << 53) + 1));
/// ```
pub trait SumCounter {
fn sum_counter(&self, metric_name: &MetricName, label_set_criteria: &LabelSet) -> Option<u64>;
}

impl SumCounter for MetricCollection {
fn sum_counter(&self, metric_name: &MetricName, label_set_criteria: &LabelSet) -> Option<u64> {
self.counters

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion][F4] One counter lookup instead of two.

This body is impl Sum for MetricKindCollection<Counter> (lines 67-73) without the as f64. Implementing SumCounter for MetricKindCollection<Counter> and having both MetricCollection::sum_counter and that Sum impl delegate to it (the latter as .map(|total| total as f64) under the existing cast_precision_loss allowance) keeps the exact and the rounded paths from drifting, and gives the public MetricKindCollection<Counter> the exact sum it now offers only rounded.

.metrics
.get(metric_name)
.map(|metric| metric.sum(label_set_criteria))
}
}

impl Sum for MetricCollection {
fn sum(&self, metric_name: &MetricName, label_set_criteria: &LabelSet) -> Option<f64> {
if let Some(value) = self.counters.sum(metric_name, label_set_criteria) {
Expand Down Expand Up @@ -146,4 +187,81 @@ mod tests {
assert_eq!(collection.sum(&metric_name!("missing_gauge"), &LabelSet::empty()), None);
}
}

mod it_should_allow_summing_counter_samples_exactly {

use torrust_clock::DurationSinceUnixEpoch;

use crate::label::{LabelSet, LabelValue};
use crate::metric_collection::MetricCollection;
use crate::metric_collection::aggregate::sum::{Sum, SumCounter};
use crate::{label_name, metric_name};

fn time() -> DurationSinceUnixEpoch {
DurationSinceUnixEpoch::from_secs(1)
}

#[test]
fn with_several_samples_and_a_label_filter() {
let name = metric_name!("test_counter");
let label_1: LabelSet = (label_name!("label_1"), LabelValue::new("value_1")).into();
let label_2: LabelSet = (label_name!("label_2"), LabelValue::new("value_2")).into();

let mut collection = MetricCollection::default();
collection.set_counter(&name, &label_1, 3, time()).unwrap();
collection.set_counter(&name, &label_2, 4, time()).unwrap();

assert_eq!(collection.sum_counter(&name, &label_1), Some(3));
assert_eq!(collection.sum_counter(&name, &label_2), Some(4));
}

#[test]
fn summing_every_sample_when_the_label_set_is_empty() {
let name = metric_name!("test_counter");

let mut collection = MetricCollection::default();
collection
.set_counter(&name, &(label_name!("label_1"), LabelValue::new("value_1")).into(), 3, time())
.unwrap();
collection
.set_counter(&name, &(label_name!("label_2"), LabelValue::new("value_2")).into(), 4, time())
.unwrap();

assert_eq!(collection.sum_counter(&name, &LabelSet::empty()), Some(7));
}

#[test]
fn returning_none_when_the_metric_does_not_exist() {
let collection = MetricCollection::default();

assert_eq!(
collection.sum_counter(&metric_name!("does_not_exist"), &LabelSet::empty()),
None
);
}

#[test]
fn returning_none_when_the_metric_is_a_gauge() {
let name = metric_name!("test_gauge");

let mut collection = MetricCollection::default();
collection.set_gauge(&name, &LabelSet::empty(), 1.0, time()).unwrap();

assert_eq!(collection.sum_counter(&name, &LabelSet::empty()), None);
}

#[test]
fn returning_values_above_two_to_the_power_of_fifty_three_exactly() {
let name = metric_name!("test_counter");
let value = (1_u64 << 53) + 1;

let mut collection = MetricCollection::default();
collection.set_counter(&name, &LabelSet::empty(), value, time()).unwrap();

assert_eq!(collection.sum_counter(&name, &LabelSet::empty()), Some(value));

// The `f64` based `Sum` rounds the same value down to `2^53`.
assert_eq!(collection.sum(&name, &LabelSet::empty()), Some(9_007_199_254_740_992.0));
}
}
}
2 changes: 1 addition & 1 deletion src/sample_collection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ mod tests {

let prometheus_output = collection.to_prometheus();

assert!(!prometheus_output.is_empty());
assert_ne!(prometheus_output, "");
}

#[test]
Expand Down
2 changes: 1 addition & 1 deletion src/unit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ mod tests {
let c = u;
assert_eq!(u, c);
let s = format!("{u:?}");
assert!(!s.is_empty());
assert_ne!(s, "");
let mut set = std::collections::HashSet::new();
set.insert(u);
assert!(set.contains(&Unit::Count));
Expand Down
Loading