Skip to content

Commit 730de9f

Browse files
committed
feat: [#1403] add new package for extendable labeled metrics
This package allow creating collection of metrics that can have labels. It's similar to the `metrics` crate. There are two types of metrics: - Counter - Gauge For example, you can increase a counter with: ```rust let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); let label_set: LabelSet = (LabelName::new("label_name"), LabelValue::new("value")).into(); let mut metric_collection = MetricCollection::new( // Collection of counter-type metrics MetricKindCollection::new(vec![ Metric::new( MetricName::new("test_counter"), SampleCollection::new(vec![Sample::new(Counter::new(0), time, label_set.clone())])) ]), // Empty colelction of gauge-type metrics MetricKindCollection::new(vec![]) ); metric_collection.increase_counter(&MetricName::new("test_counter"), &label_set, time); ``` Metric colelctions are serializable into JSON and exportable to Prometheus format.
1 parent 8a169b1 commit 730de9f

23 files changed

Lines changed: 3403 additions & 0 deletions

.github/workflows/deployment.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ jobs:
7474
cargo publish -p torrust-tracker-configuration
7575
cargo publish -p torrust-tracker-contrib-bencode
7676
cargo publish -p torrust-tracker-located-error
77+
cargo publish -p torrust-tracker-metrics
7778
cargo publish -p torrust-tracker-primitives
7879
cargo publish -p torrust-tracker-test-helpers
7980
cargo publish -p torrust-tracker-torrent-repository

cSpell.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,11 @@
5959
"Eray",
6060
"filesd",
6161
"flamegraph",
62+
"formatjson",
6263
"Freebox",
6364
"Frostegård",
6465
"gecos",
66+
"Gibibytes",
6567
"Grcov",
6668
"hasher",
6769
"healthcheck",
@@ -86,6 +88,7 @@
8688
"kcachegrind",
8789
"kexec",
8890
"keyout",
91+
"Kibibytes",
8992
"kptr",
9093
"lcov",
9194
"leecher",
@@ -96,12 +99,14 @@
9699
"LOGNAME",
97100
"Lphant",
98101
"matchmakes",
102+
"Mebibytes",
99103
"metainfo",
100104
"middlewares",
101105
"misresolved",
102106
"mockall",
103107
"multimap",
104108
"myacicontext",
109+
"ñaca",
105110
"Naim",
106111
"nanos",
107112
"newkey",
@@ -157,6 +162,7 @@
157162
"Swiftbit",
158163
"taiki",
159164
"tdyne",
165+
"Tebibytes",
160166
"tempfile",
161167
"testcontainers",
162168
"thiserror",

packages/metrics/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
./.coverage

packages/metrics/Cargo.toml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
[package]
2+
description = "A library with the primitive types shared by the Torrust tracker packages."
3+
keywords = ["api", "library", "metrics"]
4+
name = "torrust-tracker-metrics"
5+
readme = "README.md"
6+
7+
authors.workspace = true
8+
documentation.workspace = true
9+
edition.workspace = true
10+
homepage.workspace = true
11+
license.workspace = true
12+
publish.workspace = true
13+
repository.workspace = true
14+
rust-version.workspace = true
15+
version.workspace = true
16+
17+
[dependencies]
18+
chrono = { version = "0", default-features = false, features = ["clock"] }
19+
derive_more = { version = "2", features = ["constructor"] }
20+
serde = { version = "1", features = ["derive"] }
21+
serde_json = "1.0.140"
22+
thiserror = "2"
23+
torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" }
24+
25+
[dev-dependencies]
26+
approx = "0.5.1"
27+
formatjson = "0.3.1"
28+
pretty_assertions = "1.4.1"
29+
rstest = "0.25.0"

packages/metrics/LICENSE

Lines changed: 661 additions & 0 deletions
Large diffs are not rendered by default.

packages/metrics/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Torrust Tracker Metrics
2+
3+
A library with the metrics types used by the [Torrust Tracker](https://github.com/torrust/torrust-tracker) packages.
4+
5+
## Documentation
6+
7+
[Crate documentation](https://docs.rs/torrust-tracker-metrics).
8+
9+
## Acknowledgements
10+
11+
We copied some parts like units or function names and signatures from the crate [metrics](https://crates.io/crates/metrics) because we wanted to make it compatible as much as possible with it. In the future, we may consider using the `metrics` crate directly instead of maintaining our own version.
12+
13+
## License
14+
15+
The project is licensed under the terms of the [GNU AFFERO GENERAL PUBLIC LICENSE](./LICENSE).

packages/metrics/src/counter.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
use derive_more::Display;
2+
use serde::{Deserialize, Serialize};
3+
4+
use super::prometheus::PrometheusSerializable;
5+
6+
#[derive(Debug, Display, Clone, Default, PartialEq, Serialize, Deserialize)]
7+
pub struct Counter(u64);
8+
9+
impl Counter {
10+
#[must_use]
11+
pub fn new(value: u64) -> Self {
12+
Self(value)
13+
}
14+
15+
#[must_use]
16+
pub fn value(&self) -> u64 {
17+
self.0
18+
}
19+
20+
pub fn increment(&mut self, value: u64) {
21+
self.0 += value;
22+
}
23+
}
24+
25+
impl From<u64> for Counter {
26+
fn from(value: u64) -> Self {
27+
Self(value)
28+
}
29+
}
30+
31+
impl From<Counter> for u64 {
32+
fn from(counter: Counter) -> Self {
33+
counter.value()
34+
}
35+
}
36+
37+
impl PrometheusSerializable for Counter {
38+
fn to_prometheus(&self) -> String {
39+
format!("{}", self.value())
40+
}
41+
}
42+
43+
#[cfg(test)]
44+
mod tests {
45+
use super::*;
46+
47+
#[test]
48+
fn it_should_be_created_from_integer_values() {
49+
let counter = Counter::new(0);
50+
assert_eq!(counter.value(), 0);
51+
}
52+
53+
#[test]
54+
fn it_could_be_converted_from_u64() {
55+
let counter: Counter = 42.into();
56+
assert_eq!(counter.value(), 42);
57+
}
58+
59+
#[test]
60+
fn it_could_be_converted_into_u64() {
61+
let counter = Counter::new(42);
62+
let value: u64 = counter.into();
63+
assert_eq!(value, 42);
64+
}
65+
66+
#[test]
67+
fn it_could_be_incremented() {
68+
let mut counter = Counter::new(0);
69+
counter.increment(1);
70+
assert_eq!(counter.value(), 1);
71+
72+
counter.increment(2);
73+
assert_eq!(counter.value(), 3);
74+
}
75+
76+
#[test]
77+
fn it_serializes_to_prometheus() {
78+
let counter = Counter::new(42);
79+
assert_eq!(counter.to_prometheus(), "42");
80+
}
81+
}

packages/metrics/src/gauge.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
use derive_more::Display;
2+
use serde::{Deserialize, Serialize};
3+
4+
use super::prometheus::PrometheusSerializable;
5+
6+
#[derive(Debug, Display, Clone, Default, PartialEq, Serialize, Deserialize)]
7+
pub struct Gauge(f64);
8+
9+
impl Gauge {
10+
#[must_use]
11+
pub fn new(value: f64) -> Self {
12+
Self(value)
13+
}
14+
15+
#[must_use]
16+
pub fn value(&self) -> f64 {
17+
self.0
18+
}
19+
20+
pub fn set(&mut self, value: f64) {
21+
self.0 = value;
22+
}
23+
}
24+
25+
impl From<f64> for Gauge {
26+
fn from(value: f64) -> Self {
27+
Self(value)
28+
}
29+
}
30+
31+
impl From<Gauge> for f64 {
32+
fn from(counter: Gauge) -> Self {
33+
counter.value()
34+
}
35+
}
36+
37+
impl PrometheusSerializable for Gauge {
38+
fn to_prometheus(&self) -> String {
39+
format!("{}", self.value())
40+
}
41+
}
42+
43+
#[cfg(test)]
44+
mod tests {
45+
use approx::assert_relative_eq;
46+
47+
use super::*;
48+
49+
#[test]
50+
fn it_should_be_created_from_integer_values() {
51+
let gauge = Gauge::new(0.0);
52+
assert_relative_eq!(gauge.value(), 0.0);
53+
}
54+
55+
#[test]
56+
fn it_could_be_converted_from_u64() {
57+
let gauge: Gauge = 42.0.into();
58+
assert_relative_eq!(gauge.value(), 42.0);
59+
}
60+
61+
#[test]
62+
fn it_could_be_converted_into_i64() {
63+
let gauge = Gauge::new(42.0);
64+
let value: f64 = gauge.into();
65+
assert_relative_eq!(value, 42.0);
66+
}
67+
68+
#[test]
69+
fn it_could_be_set() {
70+
let mut gauge = Gauge::new(0.0);
71+
gauge.set(1.0);
72+
assert_relative_eq!(gauge.value(), 1.0);
73+
}
74+
75+
#[test]
76+
fn it_serializes_to_prometheus() {
77+
let counter = Gauge::new(42.0);
78+
assert_eq!(counter.to_prometheus(), "42");
79+
80+
let counter = Gauge::new(42.1);
81+
assert_eq!(counter.to_prometheus(), "42.1");
82+
}
83+
}

packages/metrics/src/label/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
mod name;
2+
mod pair;
3+
mod set;
4+
mod value;
5+
6+
pub type LabelName = name::LabelName;
7+
pub type LabelValue = value::LabelValue;
8+
pub type LabelPair = pair::LabelPair;
9+
pub type LabelSet = set::LabelSet;

packages/metrics/src/label/name.rs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
use derive_more::Display;
2+
use serde::{Deserialize, Serialize};
3+
4+
use crate::prometheus::PrometheusSerializable;
5+
6+
#[derive(Debug, Display, Clone, Eq, PartialEq, Default, Deserialize, Serialize, Hash, Ord, PartialOrd)]
7+
pub struct LabelName(String);
8+
9+
impl LabelName {
10+
/// Creates a new `LabelName` instance.
11+
///
12+
/// # Panics
13+
///
14+
/// Panics if the provided name is empty.
15+
#[must_use]
16+
pub fn new(name: &str) -> Self {
17+
assert!(
18+
!name.is_empty(),
19+
"Label name cannot be empty. It must have at least one character."
20+
);
21+
22+
Self(name.to_owned())
23+
}
24+
}
25+
26+
impl PrometheusSerializable for LabelName {
27+
/// In Prometheus:
28+
///
29+
/// - Labels may contain ASCII letters, numbers, as well as underscores.
30+
/// They must match the regex [a-zA-Z_][a-zA-Z0-9_]*.
31+
/// - Label names beginning with __ (two "_") are reserved for internal
32+
/// use.
33+
/// - Label values may contain any Unicode characters.
34+
/// - Labels with an empty label value are considered equivalent to
35+
/// labels that do not exist.
36+
///
37+
/// The label name is changed:
38+
///
39+
/// - If a label name starts with, or contains, an invalid character:
40+
/// replace character with underscore.
41+
/// - If th label name starts with two underscores:
42+
/// add additional underscore (three underscores total)
43+
fn to_prometheus(&self) -> String {
44+
// Replace invalid characters with underscore
45+
let processed: String = self
46+
.0
47+
.chars()
48+
.enumerate()
49+
.map(|(i, c)| {
50+
if i == 0 {
51+
if c.is_ascii_alphabetic() || c == '_' {
52+
c
53+
} else {
54+
'_'
55+
}
56+
} else if c.is_ascii_alphanumeric() || c == '_' {
57+
c
58+
} else {
59+
'_'
60+
}
61+
})
62+
.collect();
63+
64+
// If the label name starts with two underscores, add an additional
65+
if processed.starts_with("__") && !processed.starts_with("___") {
66+
format!("_{processed}")
67+
} else {
68+
processed
69+
}
70+
}
71+
}
72+
#[cfg(test)]
73+
mod tests {
74+
mod serialization_of_label_name_to_prometheus {
75+
use rstest::rstest;
76+
77+
use crate::label::LabelName;
78+
use crate::prometheus::PrometheusSerializable;
79+
80+
#[rstest]
81+
#[case("1 valid name", "valid_name", "valid_name")]
82+
#[case("2 leading underscore", "_leading_underscore", "_leading_underscore")]
83+
#[case("3 leading lowercase", "v123", "v123")]
84+
#[case("4 leading uppercase", "V123", "V123")]
85+
fn valid_names_in_prometheus(#[case] case: &str, #[case] input: &str, #[case] output: &str) {
86+
assert_eq!(LabelName::new(input).to_prometheus(), output, "{case} failed: {input:?}");
87+
}
88+
89+
#[rstest]
90+
#[case("1 invalid start 1", "9invalid_start", "_invalid_start")]
91+
#[case("2 invalid start 2", "@test", "_test")]
92+
#[case("3 invalid dash", "invalid-char", "invalid_char")]
93+
#[case("4 invalid spaces", "spaces are bad", "spaces_are_bad")]
94+
#[case("5 invalid special chars", "a!b@c#d$e%f^g&h*i(j)", "a_b_c_d_e_f_g_h_i_j_")]
95+
#[case("6 invalid colon", "my:metric/version", "my_metric_version")]
96+
#[case("7 all invalid characters", "!@#$%^&*()", "__________")]
97+
#[case("8 non_ascii_characters", "ñaca©", "_aca_")]
98+
fn names_that_need_changes_in_prometheus(#[case] case: &str, #[case] input: &str, #[case] output: &str) {
99+
assert_eq!(LabelName::new(input).to_prometheus(), output, "{case} failed: {input:?}");
100+
}
101+
102+
#[rstest]
103+
#[case("1 double underscore start", "__private", "___private")]
104+
#[case("2 double underscore only", "__", "___")]
105+
#[case("3 processed to double underscore", "^^name", "___name")]
106+
#[case("4 processed to double underscore after first char", "0__name", "___name")]
107+
fn names_starting_with_double_underscore(#[case] case: &str, #[case] input: &str, #[case] output: &str) {
108+
assert_eq!(LabelName::new(input).to_prometheus(), output, "{case} failed: {input:?}");
109+
}
110+
111+
#[test]
112+
#[should_panic(expected = "Label name cannot be empty. It must have at least one character.")]
113+
fn empty_name() {
114+
let _name = LabelName::new("");
115+
}
116+
}
117+
}

0 commit comments

Comments
 (0)