diff --git a/.github/workflows/deployment.yaml b/.github/workflows/deployment.yaml index 1422ec394..983817273 100644 --- a/.github/workflows/deployment.yaml +++ b/.github/workflows/deployment.yaml @@ -74,6 +74,7 @@ jobs: cargo publish -p torrust-tracker-configuration cargo publish -p torrust-tracker-contrib-bencode cargo publish -p torrust-tracker-located-error + cargo publish -p torrust-tracker-metrics cargo publish -p torrust-tracker-primitives cargo publish -p torrust-tracker-test-helpers cargo publish -p torrust-tracker-torrent-repository diff --git a/Cargo.lock b/Cargo.lock index 328e2db93..5feea957d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,6 +135,15 @@ version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "aquatic_peer_id" version = "0.9.0" @@ -484,6 +493,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + [[package]] name = "base64" version = "0.21.7" @@ -560,11 +578,16 @@ dependencies = [ "bittorrent-primitives", "bittorrent-tracker-core", "criterion", + "formatjson", "futures", "mockall", + "serde", + "serde_json", "thiserror 2.0.12", "tokio", + "torrust-tracker-clock", "torrust-tracker-configuration", + "torrust-tracker-metrics", "torrust-tracker-primitives", "torrust-tracker-test-helpers", "tracing", @@ -673,9 +696,12 @@ dependencies = [ "lazy_static", "mockall", "rand 0.9.0", + "serde", "thiserror 2.0.12", "tokio", + "torrust-tracker-clock", "torrust-tracker-configuration", + "torrust-tracker-metrics", "torrust-tracker-primitives", "torrust-tracker-test-helpers", "tracing", @@ -1348,6 +1374,12 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1558,6 +1590,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "formatjson" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3ba17cfe2aff8969f35b2bffec13b34756c51ea53eadcc5d5446f71370e2ed" +dependencies = [ + "miette", + "thiserror 1.0.69", +] + [[package]] name = "forwarded-header-value" version = "0.1.1" @@ -2295,6 +2337,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -2484,6 +2532,37 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "miette" +version = "7.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a955165f87b37fd1862df2a59547ac542c77ef6d17c666f619d1ad22dd89484" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "thiserror 1.0.69", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf45bf44ab49be92fd1227a3be6fc6f617f1a337c06af54981048574d8783147" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "mime" version = "0.3.17" @@ -2815,6 +2894,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "owo-colors" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1036865bb9422d3300cf723f657c2851d0e9ab12567854b1f4eba3d77decf564" + [[package]] name = "parking" version = "2.2.1" @@ -3065,6 +3150,16 @@ dependencies = [ "termtree", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro-crate" version = "3.3.0" @@ -3976,6 +4071,27 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f44ed3c63152de6a9f90acbea1a110441de43006ea51bcce8f436196a288b" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + [[package]] name = "syn" version = "1.0.109" @@ -4084,6 +4200,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal_size" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" +dependencies = [ + "rustix 1.0.3", + "windows-sys 0.59.0", +] + [[package]] name = "termtree" version = "0.5.1" @@ -4119,6 +4245,16 @@ dependencies = [ "url", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.0", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4450,6 +4586,7 @@ dependencies = [ "torrust-server-lib", "torrust-tracker-clock", "torrust-tracker-configuration", + "torrust-tracker-metrics", "torrust-tracker-primitives", "torrust-tracker-test-helpers", "torrust-udp-tracker-server", @@ -4501,6 +4638,7 @@ dependencies = [ "bittorrent-udp-tracker-core", "tokio", "torrust-tracker-configuration", + "torrust-tracker-metrics", "torrust-tracker-primitives", "torrust-tracker-test-helpers", "torrust-udp-tracker-server", @@ -4626,6 +4764,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "torrust-tracker-metrics" +version = "3.0.0-develop" +dependencies = [ + "approx", + "chrono", + "derive_more", + "formatjson", + "pretty_assertions", + "rstest", + "serde", + "serde_json", + "thiserror 2.0.12", + "torrust-tracker-primitives", +] + [[package]] name = "torrust-tracker-primitives" version = "3.0.0-develop" @@ -4690,12 +4844,14 @@ dependencies = [ "mockall", "rand 0.9.0", "ringbuf", + "serde", "thiserror 2.0.12", "tokio", "torrust-server-lib", "torrust-tracker-clock", "torrust-tracker-configuration", "torrust-tracker-located-error", + "torrust-tracker-metrics", "torrust-tracker-primitives", "torrust-tracker-test-helpers", "tracing", @@ -4862,6 +5018,24 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/cSpell.json b/cSpell.json index 3121d6175..e384a08d9 100644 --- a/cSpell.json +++ b/cSpell.json @@ -59,9 +59,11 @@ "Eray", "filesd", "flamegraph", + "formatjson", "Freebox", "Frostegård", "gecos", + "Gibibytes", "Grcov", "hasher", "healthcheck", @@ -86,6 +88,7 @@ "kcachegrind", "kexec", "keyout", + "Kibibytes", "kptr", "lcov", "leecher", @@ -96,12 +99,14 @@ "LOGNAME", "Lphant", "matchmakes", + "Mebibytes", "metainfo", "middlewares", "misresolved", "mockall", "multimap", "myacicontext", + "ñaca", "Naim", "nanos", "newkey", @@ -157,6 +162,7 @@ "Swiftbit", "taiki", "tdyne", + "Tebibytes", "tempfile", "testcontainers", "thiserror", diff --git a/packages/axum-rest-tracker-api-server/Cargo.toml b/packages/axum-rest-tracker-api-server/Cargo.toml index 42fe68584..d1491c96e 100644 --- a/packages/axum-rest-tracker-api-server/Cargo.toml +++ b/packages/axum-rest-tracker-api-server/Cargo.toml @@ -37,6 +37,7 @@ torrust-rest-tracker-api-core = { version = "3.0.0-develop", path = "../rest-tra torrust-server-lib = { version = "3.0.0-develop", path = "../server-lib" } torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } +torrust-tracker-metrics = { version = "3.0.0-develop", path = "../metrics" } torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } torrust-udp-tracker-server = { version = "3.0.0-develop", path = "../udp-tracker-server" } tower = { version = "0", features = ["timeout"] } diff --git a/packages/axum-rest-tracker-api-server/src/v1/context/stats/handlers.rs b/packages/axum-rest-tracker-api-server/src/v1/context/stats/handlers.rs index 484c12ff9..17d3e4f2d 100644 --- a/packages/axum-rest-tracker-api-server/src/v1/context/stats/handlers.rs +++ b/packages/axum-rest-tracker-api-server/src/v1/context/stats/handlers.rs @@ -9,9 +9,9 @@ use bittorrent_tracker_core::torrent::repository::in_memory::InMemoryTorrentRepo use bittorrent_udp_tracker_core::services::banning::BanService; use serde::Deserialize; use tokio::sync::RwLock; -use torrust_rest_tracker_api_core::statistics::services::get_metrics; +use torrust_rest_tracker_api_core::statistics::services::{get_labeled_metrics, get_metrics}; -use super::responses::{metrics_response, stats_response}; +use super::responses::{labeled_metrics_response, labeled_stats_response, metrics_response, stats_response}; #[derive(Deserialize, Debug, Default)] #[serde(rename_all = "lowercase")] @@ -28,7 +28,7 @@ pub struct QueryParams { pub format: Option, } -/// It handles the request to get the tracker statistics. +/// It handles the request to get the tracker global metrics. /// /// By default it returns a `200` response with the stats in JSON format. /// @@ -57,3 +57,38 @@ pub async fn get_stats_handler( None => stats_response(metrics), } } + +/// It handles the request to get the tracker extendable metrics. +/// +/// By default it returns a `200` response with the stats in JSON format. +/// +/// You can add the GET parameter `format=prometheus` to get the stats in +/// Prometheus Text Exposition Format. +#[allow(clippy::type_complexity)] +pub async fn get_metrics_handler( + State(state): State<( + Arc, + Arc>, + Arc, + Arc, + Arc, + )>, + params: Query, +) -> Response { + let metrics = get_labeled_metrics( + state.0.clone(), + state.1.clone(), + state.2.clone(), + state.3.clone(), + state.4.clone(), + ) + .await; + + match params.0.format { + Some(format) => match format { + Format::Json => labeled_stats_response(metrics), + Format::Prometheus => labeled_metrics_response(&metrics), + }, + None => labeled_stats_response(metrics), + } +} diff --git a/packages/axum-rest-tracker-api-server/src/v1/context/stats/resources.rs b/packages/axum-rest-tracker-api-server/src/v1/context/stats/resources.rs index d9480259e..8fcfd1be0 100644 --- a/packages/axum-rest-tracker-api-server/src/v1/context/stats/resources.rs +++ b/packages/axum-rest-tracker-api-server/src/v1/context/stats/resources.rs @@ -1,7 +1,8 @@ //! API resources for the [`stats`](crate::v1::context::stats) //! API context. use serde::{Deserialize, Serialize}; -use torrust_rest_tracker_api_core::statistics::services::TrackerMetrics; +use torrust_rest_tracker_api_core::statistics::services::{TrackerLabeledMetrics, TrackerMetrics}; +use torrust_tracker_metrics::metric_collection::MetricCollection; /// It contains all the statistics generated by the tracker. #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] @@ -116,6 +117,21 @@ impl From for Stats { } } +/// It contains all the statistics generated by the tracker. +#[derive(Serialize, Debug, PartialEq)] +pub struct LabeledStats { + metrics: MetricCollection, +} + +impl From for LabeledStats { + #[allow(deprecated)] + fn from(metrics: TrackerLabeledMetrics) -> Self { + Self { + metrics: metrics.metrics, + } + } +} + #[cfg(test)] mod tests { use torrust_rest_tracker_api_core::statistics::metrics::Metrics; diff --git a/packages/axum-rest-tracker-api-server/src/v1/context/stats/responses.rs b/packages/axum-rest-tracker-api-server/src/v1/context/stats/responses.rs index 853fdd2e2..e79f7e562 100644 --- a/packages/axum-rest-tracker-api-server/src/v1/context/stats/responses.rs +++ b/packages/axum-rest-tracker-api-server/src/v1/context/stats/responses.rs @@ -1,9 +1,21 @@ //! API responses for the [`stats`](crate::v1::context::stats) //! API context. use axum::response::{IntoResponse, Json, Response}; -use torrust_rest_tracker_api_core::statistics::services::TrackerMetrics; +use torrust_rest_tracker_api_core::statistics::services::{TrackerLabeledMetrics, TrackerMetrics}; +use torrust_tracker_metrics::prometheus::PrometheusSerializable; -use super::resources::Stats; +use super::resources::{LabeledStats, Stats}; + +/// `200` response that contains the [`LabeledStats`] resource as json. +#[must_use] +pub fn labeled_stats_response(tracker_metrics: TrackerLabeledMetrics) -> Response { + Json(LabeledStats::from(tracker_metrics)).into_response() +} + +#[must_use] +pub fn labeled_metrics_response(tracker_metrics: &TrackerLabeledMetrics) -> Response { + tracker_metrics.metrics.to_prometheus().into_response() +} /// `200` response that contains the [`Stats`] resource as json. #[must_use] diff --git a/packages/axum-rest-tracker-api-server/src/v1/context/stats/routes.rs b/packages/axum-rest-tracker-api-server/src/v1/context/stats/routes.rs index e92b5b34d..c19f08b2a 100644 --- a/packages/axum-rest-tracker-api-server/src/v1/context/stats/routes.rs +++ b/packages/axum-rest-tracker-api-server/src/v1/context/stats/routes.rs @@ -9,17 +9,28 @@ use axum::routing::get; use axum::Router; use torrust_rest_tracker_api_core::container::TrackerHttpApiCoreContainer; -use super::handlers::get_stats_handler; +use super::handlers::{get_metrics_handler, get_stats_handler}; /// It adds the routes to the router for the [`stats`](crate::v1::context::stats) API context. pub fn add(prefix: &str, router: Router, http_api_container: &Arc) -> Router { - router.route( - &format!("{prefix}/stats"), - get(get_stats_handler).with_state(( - http_api_container.tracker_core_container.in_memory_torrent_repository.clone(), - http_api_container.ban_service.clone(), - http_api_container.http_stats_repository.clone(), - http_api_container.udp_server_stats_repository.clone(), - )), - ) + router + .route( + &format!("{prefix}/stats"), + get(get_stats_handler).with_state(( + http_api_container.tracker_core_container.in_memory_torrent_repository.clone(), + http_api_container.ban_service.clone(), + http_api_container.http_stats_repository.clone(), + http_api_container.udp_server_stats_repository.clone(), + )), + ) + .route( + &format!("{prefix}/metrics"), + get(get_metrics_handler).with_state(( + http_api_container.tracker_core_container.in_memory_torrent_repository.clone(), + http_api_container.ban_service.clone(), + http_api_container.http_stats_repository.clone(), + http_api_container.udp_core_stats_repository.clone(), + http_api_container.udp_server_stats_repository.clone(), + )), + ) } diff --git a/packages/http-tracker-core/Cargo.toml b/packages/http-tracker-core/Cargo.toml index aaf982b04..8bd54a483 100644 --- a/packages/http-tracker-core/Cargo.toml +++ b/packages/http-tracker-core/Cargo.toml @@ -20,17 +20,21 @@ bittorrent-primitives = "0.1.0" bittorrent-tracker-core = { version = "3.0.0-develop", path = "../tracker-core" } criterion = { version = "0.5.1", features = ["async_tokio"] } futures = "0" +serde = "1.0.219" thiserror = "2" tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } +torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } +torrust-tracker-metrics = { version = "3.0.0-develop", path = "../metrics" } torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } tracing = "0" [dev-dependencies] +formatjson = "0.3.1" mockall = "0" +serde_json = "1.0.140" torrust-tracker-test-helpers = { version = "3.0.0-develop", path = "../test-helpers" } [[bench]] harness = false name = "http_tracker_core_benchmark" - diff --git a/packages/http-tracker-core/src/event/mod.rs b/packages/http-tracker-core/src/event/mod.rs index 7caf8a596..d235c179f 100644 --- a/packages/http-tracker-core/src/event/mod.rs +++ b/packages/http-tracker-core/src/event/mod.rs @@ -1,5 +1,6 @@ use std::net::{IpAddr, SocketAddr}; +use torrust_tracker_metrics::label::{LabelName, LabelSet, LabelValue}; use torrust_tracker_primitives::service_binding::ServiceBinding; pub mod sender; @@ -59,3 +60,22 @@ pub struct ClientConnectionContext { pub struct ServerConnectionContext { service_binding: ServiceBinding, } + +impl From for LabelSet { + fn from(connection_context: ConnectionContext) -> Self { + LabelSet::from([ + ( + LabelName::new("server_binding_protocol"), + LabelValue::new(&connection_context.server.service_binding.protocol().to_string()), + ), + ( + LabelName::new("server_binding_ip"), + LabelValue::new(&connection_context.server.service_binding.bind_address().ip().to_string()), + ), + ( + LabelName::new("server_binding_port"), + LabelValue::new(&connection_context.server.service_binding.bind_address().port().to_string()), + ), + ]) + } +} diff --git a/packages/http-tracker-core/src/lib.rs b/packages/http-tracker-core/src/lib.rs index 0b0b3ba78..2260242e0 100644 --- a/packages/http-tracker-core/src/lib.rs +++ b/packages/http-tracker-core/src/lib.rs @@ -3,6 +3,19 @@ pub mod event; pub mod services; pub mod statistics; +use torrust_tracker_clock::clock; + +/// This code needs to be copied into each crate. +/// Working version, for production. +#[cfg(not(test))] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Working; + +/// Stopped version, for testing. +#[cfg(test)] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Stopped; + #[cfg(test)] pub(crate) mod tests { use bittorrent_primitives::info_hash::InfoHash; diff --git a/packages/http-tracker-core/src/statistics/event/handler.rs b/packages/http-tracker-core/src/statistics/event/handler.rs index 0df1c41d3..046cb7775 100644 --- a/packages/http-tracker-core/src/statistics/event/handler.rs +++ b/packages/http-tracker-core/src/statistics/event/handler.rs @@ -1,5 +1,9 @@ use std::net::IpAddr; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + use crate::event::Event; use crate::statistics::repository::Repository; @@ -7,24 +11,52 @@ use crate::statistics::repository::Repository; /// /// This function panics if the client IP address is not the same as the IP /// version of the event. -pub async fn handle_event(event: Event, stats_repository: &Repository) { +pub async fn handle_event(event: Event, stats_repository: &Repository, now: DurationSinceUnixEpoch) { match event { - Event::TcpAnnounce { connection } => match connection.client_ip_addr() { - IpAddr::V4(_) => { - stats_repository.increase_tcp4_announces().await; - } - IpAddr::V6(_) => { - stats_repository.increase_tcp6_announces().await; - } - }, - Event::TcpScrape { connection } => match connection.client_ip_addr() { - IpAddr::V4(_) => { - stats_repository.increase_tcp4_scrapes().await; + Event::TcpAnnounce { connection } => { + // Global fixed metrics + + match connection.client_ip_addr() { + IpAddr::V4(_) => { + stats_repository.increase_tcp4_announces().await; + } + IpAddr::V6(_) => { + stats_repository.increase_tcp6_announces().await; + } } - IpAddr::V6(_) => { - stats_repository.increase_tcp6_scrapes().await; + + // Extendable metrics + + stats_repository + .increase_counter( + &MetricName::new("http_tracker_core_announce_requests_received_total"), + &LabelSet::from(connection), + now, + ) + .await; + } + Event::TcpScrape { connection } => { + // Global fixed metrics + + match connection.client_ip_addr() { + IpAddr::V4(_) => { + stats_repository.increase_tcp4_scrapes().await; + } + IpAddr::V6(_) => { + stats_repository.increase_tcp6_scrapes().await; + } } - }, + + // Extendable metrics + + stats_repository + .increase_counter( + &MetricName::new("http_tracker_core_scrape_requests_received_total"), + &LabelSet::from(connection), + now, + ) + .await; + } } tracing::debug!("stats: {:?}", stats_repository.get_stats().await); @@ -34,11 +66,13 @@ pub async fn handle_event(event: Event, stats_repository: &Repository) { mod tests { use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + use torrust_tracker_clock::clock::Time; use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; use crate::event::{ConnectionContext, Event}; use crate::statistics::event::handler::handle_event; use crate::statistics::repository::Repository; + use crate::CurrentClock; #[tokio::test] async fn should_increase_the_tcp4_announces_counter_when_it_receives_a_tcp4_announce_event() { @@ -53,6 +87,7 @@ mod tests { ), }, &stats_repository, + CurrentClock::now(), ) .await; @@ -74,6 +109,7 @@ mod tests { ), }, &stats_repository, + CurrentClock::now(), ) .await; @@ -95,6 +131,7 @@ mod tests { ), }, &stats_repository, + CurrentClock::now(), ) .await; @@ -116,6 +153,7 @@ mod tests { ), }, &stats_repository, + CurrentClock::now(), ) .await; diff --git a/packages/http-tracker-core/src/statistics/event/listener.rs b/packages/http-tracker-core/src/statistics/event/listener.rs index a03a56a21..ca53a20bb 100644 --- a/packages/http-tracker-core/src/statistics/event/listener.rs +++ b/packages/http-tracker-core/src/statistics/event/listener.rs @@ -1,13 +1,15 @@ use tokio::sync::broadcast; +use torrust_tracker_clock::clock::Time; use super::handler::handle_event; use crate::event::Event; use crate::statistics::repository::Repository; +use crate::CurrentClock; pub async fn dispatch_events(mut receiver: broadcast::Receiver, stats_repository: Repository) { loop { match receiver.recv().await { - Ok(event) => handle_event(event, &stats_repository).await, + Ok(event) => handle_event(event, &stats_repository, CurrentClock::now()).await, Err(e) => { tracing::error!("Error receiving http tracker core event: {:?}", e); break; diff --git a/packages/http-tracker-core/src/statistics/metrics.rs b/packages/http-tracker-core/src/statistics/metrics.rs index 6c102770b..0b442c1cb 100644 --- a/packages/http-tracker-core/src/statistics/metrics.rs +++ b/packages/http-tracker-core/src/statistics/metrics.rs @@ -1,12 +1,11 @@ +use serde::Serialize; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_metrics::metric_collection::MetricCollection; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + /// Metrics collected by the tracker. -/// -/// - Number of connections handled -/// - Number of `announce` requests handled -/// - Number of `scrape` request handled -/// -/// These metrics are collected for each connection type: UDP and HTTP -/// and also for each IP version used by the peers: IPv4 and IPv6. -#[derive(Debug, PartialEq, Default)] +#[derive(Debug, Clone, PartialEq, Default, Serialize)] pub struct Metrics { /// Total number of TCP (HTTP tracker) `announce` requests from IPv4 peers. pub tcp4_announces_handled: u64, @@ -19,4 +18,17 @@ pub struct Metrics { /// Total number of TCP (HTTP tracker) `scrape` requests from IPv6 peers. pub tcp6_scrapes_handled: u64, + + /// A collection of metrics. + pub metric_collection: MetricCollection, +} + +impl Metrics { + pub fn increase_counter(&mut self, metric_name: &MetricName, labels: &LabelSet, now: DurationSinceUnixEpoch) { + self.metric_collection.increase_counter(metric_name, labels, now); + } + + pub fn set_gauge(&mut self, metric_name: &MetricName, labels: &LabelSet, value: f64, now: DurationSinceUnixEpoch) { + self.metric_collection.set_gauge(metric_name, labels, value, now); + } } diff --git a/packages/http-tracker-core/src/statistics/mod.rs b/packages/http-tracker-core/src/statistics/mod.rs index 939a41061..8148df3c1 100644 --- a/packages/http-tracker-core/src/statistics/mod.rs +++ b/packages/http-tracker-core/src/statistics/mod.rs @@ -4,3 +4,27 @@ pub mod metrics; pub mod repository; pub mod services; pub mod setup; + +use metrics::Metrics; +use torrust_tracker_metrics::metric::description::MetricDescription; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_metrics::unit::Unit; + +#[must_use] +pub fn describe_metrics() -> Metrics { + let mut metrics = Metrics::default(); + + metrics.metric_collection.describe_counter( + &MetricName::new("http_tracker_core_announce_requests_received_total"), + Some(Unit::Count), + Some(MetricDescription::new("Total number of HTTP announce requests received")), + ); + + metrics.metric_collection.describe_counter( + &MetricName::new("http_tracker_core_scrape_requests_received_total"), + Some(Unit::Count), + Some(MetricDescription::new("Total number of HTTP scrape requests received")), + ); + + metrics +} diff --git a/packages/http-tracker-core/src/statistics/repository.rs b/packages/http-tracker-core/src/statistics/repository.rs index 5e15fc298..88345722b 100644 --- a/packages/http-tracker-core/src/statistics/repository.rs +++ b/packages/http-tracker-core/src/statistics/repository.rs @@ -1,7 +1,11 @@ use std::sync::Arc; use tokio::sync::{RwLock, RwLockReadGuard}; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_primitives::DurationSinceUnixEpoch; +use super::describe_metrics; use super::metrics::Metrics; /// A repository for the tracker metrics. @@ -19,9 +23,9 @@ impl Default for Repository { impl Repository { #[must_use] pub fn new() -> Self { - Self { - stats: Arc::new(RwLock::new(Metrics::default())), - } + let stats = Arc::new(RwLock::new(describe_metrics())); + + Self { stats } } pub async fn get_stats(&self) -> RwLockReadGuard<'_, Metrics> { @@ -51,4 +55,10 @@ impl Repository { stats_lock.tcp6_scrapes_handled += 1; drop(stats_lock); } + + pub async fn increase_counter(&self, metric_name: &MetricName, labels: &LabelSet, now: DurationSinceUnixEpoch) { + let mut stats_lock = self.stats.write().await; + stats_lock.increase_counter(metric_name, labels, now); + drop(stats_lock); + } } diff --git a/packages/http-tracker-core/src/statistics/services.rs b/packages/http-tracker-core/src/statistics/services.rs index dce7098b9..418b0d082 100644 --- a/packages/http-tracker-core/src/statistics/services.rs +++ b/packages/http-tracker-core/src/statistics/services.rs @@ -59,6 +59,8 @@ pub async fn get_metrics( // TCPv6 tcp6_announces_handled: stats.tcp6_announces_handled, tcp6_scrapes_handled: stats.tcp6_scrapes_handled, + // Samples + metric_collection: stats.metric_collection.clone(), }, } } @@ -73,8 +75,8 @@ mod tests { use torrust_tracker_primitives::swarm_metadata::AggregateSwarmMetadata; use torrust_tracker_test_helpers::configuration; - use crate::statistics; use crate::statistics::services::{get_metrics, TrackerMetrics}; + use crate::statistics::{self, describe_metrics}; pub fn tracker_configuration() -> Configuration { configuration::ephemeral() @@ -95,7 +97,7 @@ mod tests { tracker_metrics, TrackerMetrics { torrents_metrics: AggregateSwarmMetadata::default(), - protocol_metrics: statistics::metrics::Metrics::default(), + protocol_metrics: describe_metrics(), } ); } diff --git a/packages/metrics/.gitignore b/packages/metrics/.gitignore new file mode 100644 index 000000000..0b1372e5c --- /dev/null +++ b/packages/metrics/.gitignore @@ -0,0 +1 @@ +./.coverage diff --git a/packages/metrics/Cargo.toml b/packages/metrics/Cargo.toml new file mode 100644 index 000000000..6520cf244 --- /dev/null +++ b/packages/metrics/Cargo.toml @@ -0,0 +1,29 @@ +[package] +description = "A library with the primitive types shared by the Torrust tracker packages." +keywords = ["api", "library", "metrics"] +name = "torrust-tracker-metrics" +readme = "README.md" + +authors.workspace = true +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +publish.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +chrono = { version = "0", default-features = false, features = ["clock"] } +derive_more = { version = "2", features = ["constructor"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1.0.140" +thiserror = "2" +torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } + +[dev-dependencies] +approx = "0.5.1" +formatjson = "0.3.1" +pretty_assertions = "1.4.1" +rstest = "0.25.0" diff --git a/packages/metrics/LICENSE b/packages/metrics/LICENSE new file mode 100644 index 000000000..0ad25db4b --- /dev/null +++ b/packages/metrics/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/metrics/README.md b/packages/metrics/README.md new file mode 100644 index 000000000..627640eec --- /dev/null +++ b/packages/metrics/README.md @@ -0,0 +1,15 @@ +# Torrust Tracker Metrics + +A library with the metrics types used by the [Torrust Tracker](https://github.com/torrust/torrust-tracker) packages. + +## Documentation + +[Crate documentation](https://docs.rs/torrust-tracker-metrics). + +## Acknowledgements + +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. + +## License + +The project is licensed under the terms of the [GNU AFFERO GENERAL PUBLIC LICENSE](./LICENSE). diff --git a/packages/metrics/src/counter.rs b/packages/metrics/src/counter.rs new file mode 100644 index 000000000..3a816c75b --- /dev/null +++ b/packages/metrics/src/counter.rs @@ -0,0 +1,81 @@ +use derive_more::Display; +use serde::{Deserialize, Serialize}; + +use super::prometheus::PrometheusSerializable; + +#[derive(Debug, Display, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Counter(u64); + +impl Counter { + #[must_use] + pub fn new(value: u64) -> Self { + Self(value) + } + + #[must_use] + pub fn value(&self) -> u64 { + self.0 + } + + pub fn increment(&mut self, value: u64) { + self.0 += value; + } +} + +impl From for Counter { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl From for u64 { + fn from(counter: Counter) -> Self { + counter.value() + } +} + +impl PrometheusSerializable for Counter { + fn to_prometheus(&self) -> String { + format!("{}", self.value()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_be_created_from_integer_values() { + let counter = Counter::new(0); + assert_eq!(counter.value(), 0); + } + + #[test] + fn it_could_be_converted_from_u64() { + let counter: Counter = 42.into(); + assert_eq!(counter.value(), 42); + } + + #[test] + fn it_could_be_converted_into_u64() { + let counter = Counter::new(42); + let value: u64 = counter.into(); + assert_eq!(value, 42); + } + + #[test] + fn it_could_be_incremented() { + let mut counter = Counter::new(0); + counter.increment(1); + assert_eq!(counter.value(), 1); + + counter.increment(2); + assert_eq!(counter.value(), 3); + } + + #[test] + fn it_serializes_to_prometheus() { + let counter = Counter::new(42); + assert_eq!(counter.to_prometheus(), "42"); + } +} diff --git a/packages/metrics/src/gauge.rs b/packages/metrics/src/gauge.rs new file mode 100644 index 000000000..61ff3024c --- /dev/null +++ b/packages/metrics/src/gauge.rs @@ -0,0 +1,83 @@ +use derive_more::Display; +use serde::{Deserialize, Serialize}; + +use super::prometheus::PrometheusSerializable; + +#[derive(Debug, Display, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Gauge(f64); + +impl Gauge { + #[must_use] + pub fn new(value: f64) -> Self { + Self(value) + } + + #[must_use] + pub fn value(&self) -> f64 { + self.0 + } + + pub fn set(&mut self, value: f64) { + self.0 = value; + } +} + +impl From for Gauge { + fn from(value: f64) -> Self { + Self(value) + } +} + +impl From for f64 { + fn from(counter: Gauge) -> Self { + counter.value() + } +} + +impl PrometheusSerializable for Gauge { + fn to_prometheus(&self) -> String { + format!("{}", self.value()) + } +} + +#[cfg(test)] +mod tests { + use approx::assert_relative_eq; + + use super::*; + + #[test] + fn it_should_be_created_from_integer_values() { + let gauge = Gauge::new(0.0); + assert_relative_eq!(gauge.value(), 0.0); + } + + #[test] + fn it_could_be_converted_from_u64() { + let gauge: Gauge = 42.0.into(); + assert_relative_eq!(gauge.value(), 42.0); + } + + #[test] + fn it_could_be_converted_into_i64() { + let gauge = Gauge::new(42.0); + let value: f64 = gauge.into(); + assert_relative_eq!(value, 42.0); + } + + #[test] + fn it_could_be_set() { + let mut gauge = Gauge::new(0.0); + gauge.set(1.0); + assert_relative_eq!(gauge.value(), 1.0); + } + + #[test] + fn it_serializes_to_prometheus() { + let counter = Gauge::new(42.0); + assert_eq!(counter.to_prometheus(), "42"); + + let counter = Gauge::new(42.1); + assert_eq!(counter.to_prometheus(), "42.1"); + } +} diff --git a/packages/metrics/src/label/mod.rs b/packages/metrics/src/label/mod.rs new file mode 100644 index 000000000..b5fd3b745 --- /dev/null +++ b/packages/metrics/src/label/mod.rs @@ -0,0 +1,9 @@ +mod name; +mod pair; +mod set; +mod value; + +pub type LabelName = name::LabelName; +pub type LabelValue = value::LabelValue; +pub type LabelPair = pair::LabelPair; +pub type LabelSet = set::LabelSet; diff --git a/packages/metrics/src/label/name.rs b/packages/metrics/src/label/name.rs new file mode 100644 index 000000000..22e75572f --- /dev/null +++ b/packages/metrics/src/label/name.rs @@ -0,0 +1,117 @@ +use derive_more::Display; +use serde::{Deserialize, Serialize}; + +use crate::prometheus::PrometheusSerializable; + +#[derive(Debug, Display, Clone, Eq, PartialEq, Default, Deserialize, Serialize, Hash, Ord, PartialOrd)] +pub struct LabelName(String); + +impl LabelName { + /// Creates a new `LabelName` instance. + /// + /// # Panics + /// + /// Panics if the provided name is empty. + #[must_use] + pub fn new(name: &str) -> Self { + assert!( + !name.is_empty(), + "Label name cannot be empty. It must have at least one character." + ); + + Self(name.to_owned()) + } +} + +impl PrometheusSerializable for LabelName { + /// In Prometheus: + /// + /// - Labels may contain ASCII letters, numbers, as well as underscores. + /// They must match the regex [a-zA-Z_][a-zA-Z0-9_]*. + /// - Label names beginning with __ (two "_") are reserved for internal + /// use. + /// - Label values may contain any Unicode characters. + /// - Labels with an empty label value are considered equivalent to + /// labels that do not exist. + /// + /// The label name is changed: + /// + /// - If a label name starts with, or contains, an invalid character: + /// replace character with underscore. + /// - If th label name starts with two underscores: + /// add additional underscore (three underscores total) + fn to_prometheus(&self) -> String { + // Replace invalid characters with underscore + let processed: String = self + .0 + .chars() + .enumerate() + .map(|(i, c)| { + if i == 0 { + if c.is_ascii_alphabetic() || c == '_' { + c + } else { + '_' + } + } else if c.is_ascii_alphanumeric() || c == '_' { + c + } else { + '_' + } + }) + .collect(); + + // If the label name starts with two underscores, add an additional + if processed.starts_with("__") && !processed.starts_with("___") { + format!("_{processed}") + } else { + processed + } + } +} +#[cfg(test)] +mod tests { + mod serialization_of_label_name_to_prometheus { + use rstest::rstest; + + use crate::label::LabelName; + use crate::prometheus::PrometheusSerializable; + + #[rstest] + #[case("1 valid name", "valid_name", "valid_name")] + #[case("2 leading underscore", "_leading_underscore", "_leading_underscore")] + #[case("3 leading lowercase", "v123", "v123")] + #[case("4 leading uppercase", "V123", "V123")] + fn valid_names_in_prometheus(#[case] case: &str, #[case] input: &str, #[case] output: &str) { + assert_eq!(LabelName::new(input).to_prometheus(), output, "{case} failed: {input:?}"); + } + + #[rstest] + #[case("1 invalid start 1", "9invalid_start", "_invalid_start")] + #[case("2 invalid start 2", "@test", "_test")] + #[case("3 invalid dash", "invalid-char", "invalid_char")] + #[case("4 invalid spaces", "spaces are bad", "spaces_are_bad")] + #[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_")] + #[case("6 invalid colon", "my:metric/version", "my_metric_version")] + #[case("7 all invalid characters", "!@#$%^&*()", "__________")] + #[case("8 non_ascii_characters", "ñaca©", "_aca_")] + fn names_that_need_changes_in_prometheus(#[case] case: &str, #[case] input: &str, #[case] output: &str) { + assert_eq!(LabelName::new(input).to_prometheus(), output, "{case} failed: {input:?}"); + } + + #[rstest] + #[case("1 double underscore start", "__private", "___private")] + #[case("2 double underscore only", "__", "___")] + #[case("3 processed to double underscore", "^^name", "___name")] + #[case("4 processed to double underscore after first char", "0__name", "___name")] + fn names_starting_with_double_underscore(#[case] case: &str, #[case] input: &str, #[case] output: &str) { + assert_eq!(LabelName::new(input).to_prometheus(), output, "{case} failed: {input:?}"); + } + + #[test] + #[should_panic(expected = "Label name cannot be empty. It must have at least one character.")] + fn empty_name() { + let _name = LabelName::new(""); + } + } +} diff --git a/packages/metrics/src/label/pair.rs b/packages/metrics/src/label/pair.rs new file mode 100644 index 000000000..c89c726bd --- /dev/null +++ b/packages/metrics/src/label/pair.rs @@ -0,0 +1,29 @@ +use super::{LabelName, LabelValue}; +use crate::prometheus::PrometheusSerializable; + +pub type LabelPair = (LabelName, LabelValue); + +// Generic implementation for any tuple (A, B) where A and B implement PrometheusSerializable +impl PrometheusSerializable for (A, B) { + fn to_prometheus(&self) -> String { + format!("{}=\"{}\"", self.0.to_prometheus(), self.1.to_prometheus()) + } +} + +#[cfg(test)] +mod tests { + mod serialization_of_label_pair_to_prometheus { + use super::super::LabelName; + use crate::label::LabelValue; + use crate::prometheus::PrometheusSerializable; + + #[test] + fn test_label_pair_serialization_to_prometheus() { + let label_pair = (LabelName::new("label_name"), LabelValue::new("value")); + assert_eq!(label_pair.to_prometheus(), r#"label_name="value""#); + + let label_pair = (&LabelName::new("label_name"), &LabelValue::new("value")); + assert_eq!(label_pair.to_prometheus(), r#"label_name="value""#); + } + } +} diff --git a/packages/metrics/src/label/set.rs b/packages/metrics/src/label/set.rs new file mode 100644 index 000000000..f46b01095 --- /dev/null +++ b/packages/metrics/src/label/set.rs @@ -0,0 +1,340 @@ +use std::collections::BTreeMap; +use std::fmt::Display; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use super::{LabelName, LabelPair, LabelValue}; +use crate::prometheus::PrometheusSerializable; + +#[derive(Debug, Clone, Eq, PartialEq, Default, Ord, PartialOrd, Hash)] +pub struct LabelSet { + items: BTreeMap, +} + +impl LabelSet { + /// Insert a new label pair or update the value of an existing label. + pub fn upsert(&mut self, key: LabelName, value: LabelValue) { + self.items.insert(key, value); + } +} + +impl Display for LabelSet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let items = self + .items + .iter() + .map(|(key, value)| format!("{key}=\"{value}\"")) + .collect::>() + .join(","); + + write!(f, "{{{items}}}") + } +} + +impl From> for LabelSet { + fn from(values: BTreeMap) -> Self { + Self { items: values } + } +} + +impl From> for LabelSet { + fn from(vec: Vec<(&str, &str)>) -> Self { + let mut items = BTreeMap::new(); + + for (name, value) in vec { + items.insert(LabelName::new(name), LabelValue::new(value)); + } + + Self { items } + } +} + +impl From> for LabelSet { + fn from(vec: Vec<(String, String)>) -> Self { + let mut items = BTreeMap::new(); + + for (name, value) in vec { + items.insert(LabelName::new(&name), LabelValue::new(&value)); + } + + Self { items } + } +} + +impl From> for LabelSet { + fn from(vec: Vec) -> Self { + let mut items = BTreeMap::new(); + + for (key, value) in vec { + items.insert(key, value); + } + + Self { items } + } +} + +impl From> for LabelSet { + fn from(vec: Vec) -> Self { + let mut items = BTreeMap::new(); + + for serialized_label in vec { + items.insert(serialized_label.name, serialized_label.value); + } + + Self { items } + } +} + +impl From<[LabelPair; N]> for LabelSet { + fn from(arr: [LabelPair; N]) -> Self { + let values = BTreeMap::from(arr); + Self { items: values } + } +} + +impl From<[(String, String); N]> for LabelSet { + fn from(arr: [(String, String); N]) -> Self { + let values = arr + .iter() + .map(|(name, value)| (LabelName::new(name), LabelValue::new(value))) + .collect::>(); + Self { items: values } + } +} + +impl From<[(&str, &str); N]> for LabelSet { + fn from(arr: [(&str, &str); N]) -> Self { + let values = arr + .iter() + .map(|(name, value)| (LabelName::new(name), LabelValue::new(value))) + .collect::>(); + Self { items: values } + } +} + +impl From for LabelSet { + fn from(label_pair: LabelPair) -> Self { + let mut set = BTreeMap::new(); + + set.insert(label_pair.0, label_pair.1); + + Self { items: set } + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Default, Deserialize, Serialize)] +struct SerializedLabel { + name: LabelName, + value: LabelValue, +} + +impl Serialize for LabelSet { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.items + .iter() + .map(|(key, value)| SerializedLabel { + name: key.clone(), + value: value.clone(), + }) + .collect::>() + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for LabelSet { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let serialized_labels = Vec::::deserialize(deserializer)?; + + Ok(LabelSet::from(serialized_labels)) + } +} + +impl PrometheusSerializable for LabelSet { + fn to_prometheus(&self) -> String { + let items = self.items.iter().fold(String::new(), |mut output, label_pair| { + if !output.is_empty() { + output.push(','); + } + + output.push_str(&label_pair.to_prometheus()); + + output + }); + + format!("{{{items}}}") + } +} + +#[cfg(test)] +mod tests { + + use std::collections::BTreeMap; + + use pretty_assertions::assert_eq; + + use super::{LabelName, LabelValue}; + use crate::label::LabelSet; + use crate::prometheus::PrometheusSerializable; + + fn sample_vec_of_label_pairs() -> Vec<(LabelName, LabelValue)> { + sample_array_of_label_pairs().into() + } + + fn sample_array_of_label_pairs() -> [(LabelName, LabelValue); 3] { + [ + (LabelName::new("server_service_binding_protocol"), LabelValue::new("http")), + (LabelName::new("server_service_binding_ip"), LabelValue::new("0.0.0.0")), + (LabelName::new("server_service_binding_port"), LabelValue::new("7070")), + ] + } + + #[test] + fn it_should_allow_instantiation_from_an_array_of_label_pairs() { + let label_set: LabelSet = sample_array_of_label_pairs().into(); + + assert_eq!( + label_set, + LabelSet { + items: BTreeMap::from(sample_array_of_label_pairs()) + } + ); + } + + #[test] + fn it_should_allow_instantiation_from_a_vec_of_label_pairs() { + let label_set: LabelSet = sample_vec_of_label_pairs().into(); + + assert_eq!( + label_set, + LabelSet { + items: BTreeMap::from(sample_array_of_label_pairs()) + } + ); + } + + #[test] + fn it_should_allow_instantiation_from_a_b_tree_map() { + let label_set: LabelSet = BTreeMap::from(sample_array_of_label_pairs()).into(); + + assert_eq!( + label_set, + LabelSet { + items: BTreeMap::from(sample_array_of_label_pairs()) + } + ); + } + + #[test] + fn it_should_allow_instantiation_from_a_label_pair() { + let label_set: LabelSet = (LabelName::new("label_name"), LabelValue::new("value")).into(); + + assert_eq!( + label_set, + LabelSet { + items: BTreeMap::from([(LabelName::new("label_name"), LabelValue::new("value"))]) + } + ); + } + + #[test] + fn it_should_allow_inserting_a_new_label_pair() { + let mut label_set = LabelSet::default(); + + label_set.upsert(LabelName::new("label_name"), LabelValue::new("value")); + + assert_eq!( + label_set.items.get(&LabelName::new("label_name")).unwrap(), + &LabelValue::new("value") + ); + } + + #[test] + fn it_should_allow_updating_a_label_value() { + let mut label_set = LabelSet::default(); + + label_set.upsert(LabelName::new("label_name"), LabelValue::new("old value")); + label_set.upsert(LabelName::new("label_name"), LabelValue::new("new value")); + + assert_eq!( + label_set.items.get(&LabelName::new("label_name")).unwrap(), + &LabelValue::new("new value") + ); + } + + #[test] + fn it_should_allow_serializing_to_json_as_an_array_of_label_objects() { + let label_set = LabelSet::from((LabelName::new("label_name"), LabelValue::new("label value"))); + + let json = serde_json::to_string(&label_set).unwrap(); + + assert_eq!( + formatjson::format_json(&json).unwrap(), + formatjson::format_json( + r#" + [ + { + "name": "label_name", + "value": "label value" + } + ] + "# + ) + .unwrap() + ); + } + + #[test] + fn it_should_allow_deserializing_from_json_as_an_array_of_label_objects() { + let json = formatjson::format_json( + r#" + [ + { + "name": "label_name", + "value": "label value" + } + ] + "#, + ) + .unwrap(); + + let label_set: LabelSet = serde_json::from_str(&json).unwrap(); + + assert_eq!( + label_set, + LabelSet::from((LabelName::new("label_name"), LabelValue::new("label value"))) + ); + } + + #[test] + fn it_should_allow_serializing_to_prometheus_format() { + let label_set = LabelSet::from((LabelName::new("label_name"), LabelValue::new("label value"))); + + assert_eq!(label_set.to_prometheus(), r#"{label_name="label value"}"#); + } + + #[test] + fn it_should_alphabetically_order_labels_in_prometheus_format() { + let label_set = LabelSet::from([ + (LabelName::new("b_label_name"), LabelValue::new("b label value")), + (LabelName::new("a_label_name"), LabelValue::new("a label value")), + ]); + + assert_eq!( + label_set.to_prometheus(), + r#"{a_label_name="a label value",b_label_name="b label value"}"# + ); + } + + #[test] + fn it_should_allow_displaying() { + let label_set = LabelSet::from((LabelName::new("label_name"), LabelValue::new("label value"))); + + assert_eq!(label_set.to_string(), r#"{label_name="label value"}"#); + } +} diff --git a/packages/metrics/src/label/value.rs b/packages/metrics/src/label/value.rs new file mode 100644 index 000000000..528a0e2ab --- /dev/null +++ b/packages/metrics/src/label/value.rs @@ -0,0 +1,38 @@ +use derive_more::Display; +use serde::{Deserialize, Serialize}; + +use crate::prometheus::PrometheusSerializable; + +#[derive(Debug, Display, Clone, Eq, PartialEq, Default, Deserialize, Serialize, Hash, Ord, PartialOrd)] +pub struct LabelValue(String); + +impl LabelValue { + #[must_use] + pub fn new(value: &str) -> Self { + Self(value.to_owned()) + } + + /// Empty label values are ignored in Prometheus. + #[must_use] + pub fn ignore() -> Self { + Self(String::default()) + } +} + +impl PrometheusSerializable for LabelValue { + fn to_prometheus(&self) -> String { + self.0.clone() + } +} + +#[cfg(test)] +mod tests { + use crate::label::value::LabelValue; + use crate::prometheus::PrometheusSerializable; + + #[test] + fn it_serializes_to_prometheus() { + let label_value = LabelValue::new("value"); + assert_eq!(label_value.to_prometheus(), "value"); + } +} diff --git a/packages/metrics/src/lib.rs b/packages/metrics/src/lib.rs new file mode 100644 index 000000000..fd677b891 --- /dev/null +++ b/packages/metrics/src/lib.rs @@ -0,0 +1,28 @@ +pub mod counter; +pub mod gauge; +pub mod label; +pub mod metric; +pub mod metric_collection; +pub mod prometheus; +pub mod sample; +pub mod sample_collection; +pub mod unit; + +#[cfg(test)] +mod tests { + /// It removes leading and trailing whitespace from each line, and empty lines. + pub fn format_prometheus_output(output: &str) -> String { + output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join("\n") + } + + pub fn sort_lines(s: &str) -> String { + let mut lines: Vec<&str> = s.split('\n').collect(); + lines.sort_unstable(); + lines.join("\n") + } +} diff --git a/packages/metrics/src/metric/description.rs b/packages/metrics/src/metric/description.rs new file mode 100644 index 000000000..8a50dee90 --- /dev/null +++ b/packages/metrics/src/metric/description.rs @@ -0,0 +1,29 @@ +use derive_more::Display; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Display, Clone, Eq, PartialEq, Default, Deserialize, Serialize, Hash, Ord, PartialOrd)] +pub struct MetricDescription(String); + +impl MetricDescription { + #[must_use] + pub fn new(name: &str) -> Self { + Self(name.to_owned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_be_created_from_a_string_reference() { + let metric = MetricDescription::new("Metric description"); + assert_eq!(metric.0, "Metric description"); + } + + #[test] + fn it_should_be_displayed() { + let metric = MetricDescription::new("Metric description"); + assert_eq!(metric.to_string(), "Metric description"); + } +} diff --git a/packages/metrics/src/metric/mod.rs b/packages/metrics/src/metric/mod.rs new file mode 100644 index 000000000..edea035bb --- /dev/null +++ b/packages/metrics/src/metric/mod.rs @@ -0,0 +1,195 @@ +pub mod description; +pub mod name; + +use serde::{Deserialize, Serialize}; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use super::counter::Counter; +use super::label::LabelSet; +use super::prometheus::PrometheusSerializable; +use super::sample_collection::SampleCollection; +use crate::gauge::Gauge; +use crate::sample::Measurement; + +pub type MetricName = name::MetricName; + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Metric { + name: MetricName, + + #[serde(rename = "samples")] + sample_collection: SampleCollection, +} + +impl Metric { + #[must_use] + pub fn new(name: MetricName, samples: SampleCollection) -> Self { + Self { + name, + sample_collection: samples, + } + } + + #[must_use] + pub fn name(&self) -> &MetricName { + &self.name + } + + #[must_use] + pub fn get_sample_data(&self, label_set: &LabelSet) -> Option<&Measurement> { + self.sample_collection.get(label_set) + } + + #[must_use] + pub fn number_of_samples(&self) -> usize { + self.sample_collection.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.sample_collection.is_empty() + } +} + +impl Metric { + pub fn increment(&mut self, label_set: &LabelSet, time: DurationSinceUnixEpoch) { + self.sample_collection.increment(label_set, time); + } +} + +impl Metric { + pub fn set(&mut self, label_set: &LabelSet, value: f64, time: DurationSinceUnixEpoch) { + self.sample_collection.set(label_set, value, time); + } +} + +impl PrometheusSerializable for Metric { + fn to_prometheus(&self) -> String { + let samples: Vec = self + .sample_collection + .iter() + .map(|(label_set, sample)| { + format!( + "{}{} {}", + self.name.to_prometheus(), + label_set.to_prometheus(), + sample.value().to_prometheus() + ) + }) + .collect(); + samples.join("\n") + } +} + +#[cfg(test)] +mod tests { + mod for_generic_metrics { + use super::super::*; + use crate::gauge::Gauge; + use crate::label::{LabelName, LabelValue}; + use crate::sample::Sample; + + #[test] + fn it_should_be_empty_when_it_does_not_have_any_sample() { + let name = MetricName::new("test_metric"); + + let samples = SampleCollection::::default(); + + let metric = Metric::::new(name.clone(), samples); + + assert!(metric.is_empty()); + } + + fn counter_metric_with_one_sample() -> Metric { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + + let name = MetricName::new("test_metric"); + + let label_set: LabelSet = [(LabelName::new("server_binding_protocol"), LabelValue::new("http"))].into(); + + let samples = SampleCollection::new(vec![Sample::new(Counter::new(1), time, label_set.clone())]); + + Metric::::new(name.clone(), samples) + } + + #[test] + fn it_should_return_the_number_of_samples() { + assert_eq!(counter_metric_with_one_sample().number_of_samples(), 1); + } + + #[test] + fn it_should_return_zero_number_of_samples_for_an_empty_metric() { + let name = MetricName::new("test_metric"); + + let samples = SampleCollection::::default(); + + let metric = Metric::::new(name.clone(), samples); + + assert_eq!(metric.number_of_samples(), 0); + } + } + + mod for_counter_metrics { + use super::super::*; + use crate::counter::Counter; + use crate::label::{LabelName, LabelValue}; + use crate::sample::Sample; + + #[test] + fn it_should_be_created_from_its_name_and_a_collection_of_samples() { + let name = MetricName::new("test_metric"); + + let samples = SampleCollection::::default(); + + let _metric = Metric::::new(name, samples); + } + + #[test] + fn it_should_allow_incrementing_a_sample() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + + let name = MetricName::new("test_metric"); + + let label_set: LabelSet = [(LabelName::new("server_binding_protocol"), LabelValue::new("http"))].into(); + + let samples = SampleCollection::new(vec![Sample::new(Counter::new(1), time, label_set.clone())]); + + let metric = Metric::::new(name.clone(), samples); + + assert_eq!(metric.get_sample_data(&label_set).unwrap().value().value(), 1); + } + } + + mod for_gauge_metrics { + use approx::assert_relative_eq; + + use super::super::*; + use crate::gauge::Gauge; + use crate::label::{LabelName, LabelValue}; + use crate::sample::Sample; + + #[test] + fn it_should_be_created_from_its_name_and_a_collection_of_samples() { + let name = MetricName::new("test_metric"); + + let samples = SampleCollection::::default(); + + let _metric = Metric::::new(name, samples); + } + + #[test] + fn it_should_allow_setting_a_sample() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + + let name = MetricName::new("test_metric"); + + let label_set: LabelSet = [(LabelName::new("server_binding_protocol"), LabelValue::new("http"))].into(); + + let samples = SampleCollection::new(vec![Sample::new(Gauge::new(1.0), time, label_set.clone())]); + + let metric = Metric::::new(name.clone(), samples); + + assert_relative_eq!(metric.get_sample_data(&label_set).unwrap().value().value(), 1.0); + } + } +} diff --git a/packages/metrics/src/metric/name.rs b/packages/metrics/src/metric/name.rs new file mode 100644 index 000000000..c904f34d3 --- /dev/null +++ b/packages/metrics/src/metric/name.rs @@ -0,0 +1,92 @@ +use derive_more::Display; +use serde::{Deserialize, Serialize}; + +use crate::prometheus::PrometheusSerializable; + +#[derive(Debug, Display, Clone, Eq, PartialEq, Default, Deserialize, Serialize, Hash, Ord, PartialOrd)] +pub struct MetricName(String); + +impl MetricName { + /// Creates a new `MetricName` instance. + /// + /// # Panics + /// + /// Panics if the provided name is empty. + #[must_use] + pub fn new(name: &str) -> Self { + assert!( + !name.is_empty(), + "Metric name cannot be empty. It must have at least one character." + ); + + Self(name.to_owned()) + } +} + +impl PrometheusSerializable for MetricName { + fn to_prometheus(&self) -> String { + // Metric names may contain ASCII letters, digits, underscores, and + // colons. It must match the regex [a-zA-Z_:][a-zA-Z0-9_:]*. + // If the metric name starts with, or contains, an invalid character: + // replace character with underscore. + + self.0 + .chars() + .enumerate() + .map(|(i, c)| { + if i == 0 { + if c.is_ascii_alphabetic() || c == '_' || c == ':' { + c + } else { + '_' + } + } else if c.is_ascii_alphanumeric() || c == '_' || c == ':' { + c + } else { + '_' + } + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + + mod serialization_of_metric_name_to_prometheus { + + use rstest::rstest; + + use crate::metric::MetricName; + use crate::prometheus::PrometheusSerializable; + + #[rstest] + #[case("valid name", "valid_name", "valid_name")] + #[case("leading underscore", "_leading_underscore", "_leading_underscore")] + #[case("leading colon", ":leading_colon", ":leading_colon")] + #[case("leading lowercase", "v123", "v123")] + #[case("leading uppercase", "V123", "V123")] + fn valid_names_in_prometheus(#[case] case: &str, #[case] input: &str, #[case] output: &str) { + assert_eq!(MetricName::new(input).to_prometheus(), output, "{case} failed: {input:?}"); + } + + #[rstest] + #[case("invalid start 1", "9invalid_start", "_invalid_start")] + #[case("invalid start 2", "@test", "_test")] + #[case("invalid dash", "invalid-char", "invalid_char")] + #[case("invalid spaces", "spaces are bad", "spaces_are_bad")] + #[case("invalid special chars", "a!b@c#d$e%f^g&h*i(j)", "a_b_c_d_e_f_g_h_i_j_")] + #[case("invalid slash", "my:metric/version", "my:metric_version")] + #[case("all invalid characters", "!@#$%^&*()", "__________")] + #[case("non_ascii_characters", "ñaca©", "_aca_")] + fn names_that_need_changes_in_prometheus(#[case] case: &str, #[case] input: &str, #[case] output: &str) { + assert_eq!(MetricName::new(input).to_prometheus(), output, "{case} failed: {input:?}"); + } + + #[test] + #[should_panic(expected = "Metric name cannot be empty. It must have at least one character.")] + fn empty_name() { + let _name = MetricName::new(""); + } + } +} diff --git a/packages/metrics/src/metric_collection.rs b/packages/metrics/src/metric_collection.rs new file mode 100644 index 000000000..d0ed96554 --- /dev/null +++ b/packages/metrics/src/metric_collection.rs @@ -0,0 +1,759 @@ +use std::collections::{HashMap, HashSet}; + +use serde::ser::{SerializeSeq, Serializer}; +use serde::{Deserialize, Deserializer, Serialize}; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use super::counter::Counter; +use super::gauge::Gauge; +use super::label::LabelSet; +use super::metric::{Metric, MetricName}; +use super::prometheus::PrometheusSerializable; +use crate::metric::description::MetricDescription; +use crate::sample_collection::SampleCollection; +use crate::unit::Unit; + +// todo: serialize in a deterministic order. For example: +// - First the counter metrics ordered by name. +// - Then the gauge metrics ordered by name. + +/// Use this type only when behind a lock that guarantees thread-safety. +/// Otherwise, there could be race conditions that lead to duplicate metric +/// names in different metric types. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct MetricCollection { + counters: MetricKindCollection, + gauges: MetricKindCollection, +} + +impl MetricCollection { + /// # Panics + /// + /// Panics if there are duplicate metric names across counters and gauges. + #[must_use] + pub fn new(counters: MetricKindCollection, gauges: MetricKindCollection) -> Self { + // Check for name collisions across metric types + let counter_names: HashSet<_> = counters.names().collect(); + let gauge_names: HashSet<_> = gauges.names().collect(); + + assert!( + counter_names.is_disjoint(&gauge_names), + "Metric names must be unique across counters and gauges" + ); + + Self { counters, gauges } + } + + /// Merges another `MetricCollection` into this one. + /// + /// # Errors + /// + /// Returns an error if a metric name already exists in the current collection. + pub fn merge(&mut self, other: &Self) -> Result<(), MergeError> { + self.counters.merge(&other.counters)?; + self.gauges.merge(&other.gauges)?; + Ok(()) + } + + // Counter-specific methods + + pub fn describe_counter(&mut self, name: &MetricName, _opt_unit: Option, _opt_description: Option) { + self.counters.ensure_metric_exists(name); + } + + #[must_use] + pub fn get_counter_value(&self, name: &MetricName, label_set: &LabelSet) -> Counter { + self.counters.get_value(name, label_set) + } + + /// # Panics + /// + /// Panics if a gauge with the same name already exists. + pub fn increase_counter(&mut self, name: &MetricName, label_set: &LabelSet, time: DurationSinceUnixEpoch) { + assert!( + !self.gauges.metrics.contains_key(name), + "Cannot create counter with name '{name}': a gauge with this name already exists", + ); + + self.counters.increment(name, label_set, time); + } + + pub fn ensure_counter_exists(&mut self, name: &MetricName) { + self.counters.ensure_metric_exists(name); + } + + // Gauge-specific methods + + pub fn describe_gauge(&mut self, name: &MetricName, _opt_unit: Option, _opt_description: Option) { + self.gauges.ensure_metric_exists(name); + } + + #[must_use] + pub fn get_gauge_value(&self, name: &MetricName, label_set: &LabelSet) -> Gauge { + self.gauges.get_value(name, label_set) + } + + /// # Panics + /// + /// Panics if a counter with the same name already exists. + pub fn set_gauge(&mut self, name: &MetricName, label_set: &LabelSet, value: f64, time: DurationSinceUnixEpoch) { + assert!( + !self.counters.metrics.contains_key(name), + "Cannot create gauge with name '{name}': a counter with this name already exists" + ); + + self.gauges.set(name, label_set, value, time); + } + + pub fn ensure_gauge_exists(&mut self, name: &MetricName) { + self.gauges.ensure_metric_exists(name); + } +} + +#[derive(thiserror::Error, Debug, Clone)] +pub enum MergeError { + #[error("Cannot merge metric '{metric_name}': it already exists in the current collection")] + MetricNameAlreadyExists { metric_name: MetricName }, +} + +/// Implements serialization for `MetricCollection`. +impl Serialize for MetricCollection { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + #[derive(Serialize)] + #[serde(tag = "kind", rename_all = "lowercase")] + enum SerializableMetric<'a> { + Counter(&'a Metric), + Gauge(&'a Metric), + } + + let mut seq = serializer.serialize_seq(Some(self.counters.metrics.len() + self.gauges.metrics.len()))?; + + for metric in self.counters.metrics.values() { + seq.serialize_element(&SerializableMetric::Counter(metric))?; + } + + for metric in self.gauges.metrics.values() { + seq.serialize_element(&SerializableMetric::Gauge(metric))?; + } + + seq.end() + } +} + +impl<'de> Deserialize<'de> for MetricCollection { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(tag = "kind", rename_all = "lowercase")] + enum MetricPayload { + Counter(Metric), + Gauge(Metric), + } + + let payload = Vec::::deserialize(deserializer)?; + + let mut counters = Vec::new(); + let mut gauges = Vec::new(); + + for metric in payload { + match metric { + MetricPayload::Counter(counter) => counters.push(counter), + MetricPayload::Gauge(gauge) => gauges.push(gauge), + } + } + + Ok(MetricCollection::new( + MetricKindCollection::new(counters), + MetricKindCollection::new(gauges), + )) + } +} + +impl PrometheusSerializable for MetricCollection { + fn to_prometheus(&self) -> String { + self.counters + .metrics + .values() + .filter(|metric| !metric.is_empty()) + .map(Metric::::to_prometheus) + .chain( + self.gauges + .metrics + .values() + .filter(|metric| !metric.is_empty()) + .map(Metric::::to_prometheus), + ) + .collect::>() + .join("\n") + } +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct MetricKindCollection { + metrics: HashMap>, +} + +impl MetricKindCollection { + /// Creates a new `MetricKindCollection` from a vector of metrics + /// + /// # Panics + /// + /// Panics if duplicate metric names are found + #[must_use] + pub fn new(metrics: Vec>) -> Self { + let mut map = HashMap::with_capacity(metrics.len()); + + for metric in metrics { + assert!( + map.insert(metric.name().clone(), metric).is_none(), + "Duplicate MetricName found in MetricKindCollection" + ); + } + Self { metrics: map } + } + + /// Returns an iterator over all metric names in this collection. + pub fn names(&self) -> impl Iterator { + self.metrics.keys() + } + + pub fn ensure_metric_exists(&mut self, name: &MetricName) { + if !self.metrics.contains_key(name) { + self.metrics + .insert(name.clone(), Metric::new(name.clone(), SampleCollection::new(vec![]))); + } + } +} + +impl MetricKindCollection { + /// Merges another `MetricKindCollection` into this one. + /// + /// # Errors + /// + /// Returns an error if a metric name already exists in the current collection. + pub fn merge(&mut self, other: &Self) -> Result<(), MergeError> { + // Check for name collisions + for metric_name in other.metrics.keys() { + if self.metrics.contains_key(metric_name) { + return Err(MergeError::MetricNameAlreadyExists { + metric_name: metric_name.clone(), + }); + } + } + + for (metric_name, metric) in &other.metrics { + if self.metrics.insert(metric_name.clone(), metric.clone()).is_some() { + return Err(MergeError::MetricNameAlreadyExists { + metric_name: metric_name.clone(), + }); + } + } + + Ok(()) + } +} + +impl MetricKindCollection { + /// Increments the counter for the given metric name and labels. + /// + /// If the metric name does not exist, it will be created. + /// + /// # Panics + /// + /// Panics if the metric does not exist and it could not be created. + pub fn increment(&mut self, name: &MetricName, label_set: &LabelSet, time: DurationSinceUnixEpoch) { + self.ensure_metric_exists(name); + + let metric = self.metrics.get_mut(name).expect("Counter metric should exist"); + + metric.increment(label_set, time); + } + + #[must_use] + pub fn get_value(&self, name: &MetricName, label_set: &LabelSet) -> Counter { + self.metrics + .get(name) + .and_then(|metric| metric.get_sample_data(label_set)) + .map_or(Counter::default(), |sample| sample.value().clone()) + } +} + +impl MetricKindCollection { + /// Sets the gauge for the given metric name and labels. + /// + /// If the metric name does not exist, it will be created. + /// + /// # Panics + /// + /// Panics if the metric does not exist and it could not be created. + pub fn set(&mut self, name: &MetricName, label_set: &LabelSet, value: f64, time: DurationSinceUnixEpoch) { + self.ensure_metric_exists(name); + + let metric = self.metrics.get_mut(name).expect("Gauge metric should exist"); + + metric.set(label_set, value, time); + } + + #[must_use] + pub fn get_value(&self, name: &MetricName, label_set: &LabelSet) -> Gauge { + self.metrics + .get(name) + .and_then(|metric| metric.get_sample_data(label_set)) + .map_or(Gauge::default(), |sample| sample.value().clone()) + } +} + +#[cfg(test)] +mod tests { + + use pretty_assertions::assert_eq; + + use super::*; + use crate::label::{LabelName, LabelValue}; + use crate::sample::Sample; + use crate::tests::{format_prometheus_output, sort_lines}; + + /// Fixture for testing serialization and deserialization of `MetricCollection`. + /// + /// It contains a default `MetricCollection` object, its JSON representation, + /// and its Prometheus format representation. + struct MetricCollectionFixture { + pub object: MetricCollection, + pub json: String, + pub prometheus: String, + } + + impl Default for MetricCollectionFixture { + fn default() -> Self { + Self { + object: Self::object(), + json: Self::json(), + prometheus: Self::prometheus(), + } + } + } + + impl MetricCollectionFixture { + fn deconstruct(&self) -> (MetricCollection, String, String) { + (self.object.clone(), self.json.clone(), self.prometheus.clone()) + } + + fn object() -> MetricCollection { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + + let label_set_1: LabelSet = [ + (LabelName::new("server_binding_protocol"), LabelValue::new("http")), + (LabelName::new("server_binding_ip"), LabelValue::new("0.0.0.0")), + (LabelName::new("server_binding_port"), LabelValue::new("7070")), + ] + .into(); + + MetricCollection::new( + MetricKindCollection::new(vec![Metric::new( + MetricName::new("http_tracker_core_announce_requests_received_total"), + SampleCollection::new(vec![Sample::new(Counter::new(1), time, label_set_1.clone())]), + )]), + MetricKindCollection::new(vec![Metric::new( + MetricName::new("udp_tracker_server_performance_avg_announce_processing_time_ns"), + SampleCollection::new(vec![Sample::new(Gauge::new(1.0), time, label_set_1.clone())]), + )]), + ) + } + + fn json() -> String { + r#" + [ + { + "kind":"counter", + "name":"http_tracker_core_announce_requests_received_total", + "samples":[ + { + "value":1, + "recorded_at":"2025-04-02T00:00:00+00:00", + "labels":[ + { + "name":"server_binding_ip", + "value":"0.0.0.0" + }, + { + "name":"server_binding_port", + "value":"7070" + }, + { + "name":"server_binding_protocol", + "value":"http" + } + ] + } + ] + }, + { + "kind":"gauge", + "name":"udp_tracker_server_performance_avg_announce_processing_time_ns", + "samples":[ + { + "value":1.0, + "recorded_at":"2025-04-02T00:00:00+00:00", + "labels":[ + { + "name":"server_binding_ip", + "value":"0.0.0.0" + }, + { + "name":"server_binding_port", + "value":"7070" + }, + { + "name":"server_binding_protocol", + "value":"http" + } + ] + } + ] + } + ] + "# + .to_owned() + } + + fn prometheus() -> String { + format_prometheus_output( + r#" + http_tracker_core_announce_requests_received_total{server_binding_ip="0.0.0.0",server_binding_port="7070",server_binding_protocol="http"} 1 + udp_tracker_server_performance_avg_announce_processing_time_ns{server_binding_ip="0.0.0.0",server_binding_port="7070",server_binding_protocol="http"} 1 + "#, + ) + } + } + + #[test] + #[should_panic(expected = "Metric names must be unique across counters and gauges")] + fn it_should_not_allow_duplicate_names_across_types() { + let counter = MetricKindCollection::new(vec![Metric::new( + MetricName::new("test_metric"), + SampleCollection::new(vec![]), + )]); + + let gauge = MetricKindCollection::new(vec![Metric::new( + MetricName::new("test_metric"), + SampleCollection::new(vec![]), + )]); + + let _unused = MetricCollection::new(counter, gauge); + } + + #[test] + #[should_panic(expected = "Cannot create gauge with name 'test_metric': a counter with this name already exists")] + fn it_should_not_allow_creating_a_gauge_with_the_same_name_as_a_counter() { + let mut collection = MetricCollection::default(); + let label_set = LabelSet::default(); + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + + // First create a counter + collection.increase_counter(&MetricName::new("test_metric"), &label_set, time); + + // Then try to create a gauge with the same name - this should panic + collection.set_gauge(&MetricName::new("test_metric"), &label_set, 1.0, time); + } + + #[test] + #[should_panic(expected = "Cannot create counter with name 'test_metric': a gauge with this name already exists")] + fn it_should_not_allow_creating_a_counter_with_the_same_name_as_a_gauge() { + let mut collection = MetricCollection::default(); + let label_set = LabelSet::default(); + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + + // First set the gauge + collection.set_gauge(&MetricName::new("test_metric"), &label_set, 1.0, time); + + // Then try to create a counter with the same name - this should panic + collection.increase_counter(&MetricName::new("test_metric"), &label_set, time); + } + + #[test] + fn it_should_allow_serializing_to_json() { + // todo: this test does work with metric with multiple samples becuase + // samples are not serialized in the same order as they are created. + let (metric_collection, expected_json, _expected_prometheus) = MetricCollectionFixture::default().deconstruct(); + + let json = serde_json::to_string_pretty(&metric_collection).unwrap(); + + assert_eq!( + serde_json::from_str::(&json).unwrap(), + serde_json::from_str::(&expected_json).unwrap() + ); + } + + #[test] + fn it_should_allow_deserializing_from_json() { + let (expected_metric_collection, metric_collection_json, _expected_prometheus) = + MetricCollectionFixture::default().deconstruct(); + + let metric_collection: MetricCollection = serde_json::from_str(&metric_collection_json).unwrap(); + + assert_eq!(metric_collection, expected_metric_collection); + } + + #[test] + fn it_should_allow_serializing_to_prometheus_format() { + let (metric_collection, _expected_json, expected_prometheus) = MetricCollectionFixture::default().deconstruct(); + + let prometheus_output = metric_collection.to_prometheus(); + + assert_eq!(prometheus_output, expected_prometheus); + } + + #[test] + fn it_should_allow_serializing_to_prometheus_format_with_multiple_samples_per_metric() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + + let label_set_1: LabelSet = [ + (LabelName::new("server_binding_protocol"), LabelValue::new("http")), + (LabelName::new("server_binding_ip"), LabelValue::new("0.0.0.0")), + (LabelName::new("server_binding_port"), LabelValue::new("7070")), + ] + .into(); + + let label_set_2: LabelSet = [ + (LabelName::new("server_binding_protocol"), LabelValue::new("http")), + (LabelName::new("server_binding_ip"), LabelValue::new("0.0.0.0")), + (LabelName::new("server_binding_port"), LabelValue::new("7171")), + ] + .into(); + + let metric_collection = MetricCollection::new( + MetricKindCollection::new(vec![Metric::new( + MetricName::new("http_tracker_core_announce_requests_received_total"), + SampleCollection::new(vec![ + Sample::new(Counter::new(1), time, label_set_1.clone()), + Sample::new(Counter::new(2), time, label_set_2.clone()), + ]), + )]), + MetricKindCollection::new(vec![]), + ); + + let prometheus_output = metric_collection.to_prometheus(); + + let expected_prometheus_output = format_prometheus_output( + r#" + http_tracker_core_announce_requests_received_total{server_binding_ip="0.0.0.0",server_binding_port="7171",server_binding_protocol="http"} 2 + http_tracker_core_announce_requests_received_total{server_binding_ip="0.0.0.0",server_binding_port="7070",server_binding_protocol="http"} 1 + "#, + ); + + // code-review: samples are not serialized in the same order as they are created. + // Should we use a deterministic order? + + assert_eq!(sort_lines(&prometheus_output), sort_lines(&expected_prometheus_output)); + } + + #[test] + fn it_should_exclude_metrics_without_samples_from_prometheus_format() { + let mut counters = MetricKindCollection::new(vec![]); + let mut gauges = MetricKindCollection::new(vec![]); + + counters.ensure_metric_exists(&MetricName::new("test_counter")); + gauges.ensure_metric_exists(&MetricName::new("test_gauge")); + + let metric_collection = MetricCollection::new(counters, gauges); + + let prometheus_output = metric_collection.to_prometheus(); + + assert_eq!(prometheus_output, ""); + } + + mod for_counters { + + use pretty_assertions::assert_eq; + + use super::*; + use crate::label::{LabelName, LabelValue}; + use crate::sample::Sample; + + #[test] + fn it_should_increase_a_preexistent_counter() { + 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( + MetricKindCollection::new(vec![Metric::new( + MetricName::new("test_counter"), + SampleCollection::new(vec![Sample::new(Counter::new(0), time, label_set.clone())]), + )]), + MetricKindCollection::new(vec![]), + ); + + metric_collection.increase_counter(&MetricName::new("test_counter"), &label_set, time); + metric_collection.increase_counter(&MetricName::new("test_counter"), &label_set, time); + + assert_eq!( + metric_collection.get_counter_value(&MetricName::new("test_counter"), &label_set), + Counter::new(2) + ); + } + + #[test] + fn it_should_automatically_create_a_counter_when_increasing_if_it_does_not_exist() { + 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(MetricKindCollection::new(vec![]), MetricKindCollection::new(vec![])); + + metric_collection.increase_counter(&MetricName::new("test_counter"), &label_set, time); + metric_collection.increase_counter(&MetricName::new("test_counter"), &label_set, time); + + assert_eq!( + metric_collection.get_counter_value(&MetricName::new("test_counter"), &label_set), + Counter::new(2) + ); + } + + #[test] + fn it_should_allow_making_sure_a_counter_exists_without_increasing_it() { + let label_set: LabelSet = (LabelName::new("label_name"), LabelValue::new("value")).into(); + + let mut metric_collection = + MetricCollection::new(MetricKindCollection::new(vec![]), MetricKindCollection::new(vec![])); + + metric_collection.ensure_counter_exists(&MetricName::new("test_counter")); + + assert_eq!( + metric_collection.get_counter_value(&MetricName::new("test_counter"), &label_set), + Counter::default() + ); + } + + #[test] + fn it_should_allow_describing_a_counter_before_using_it() { + let label_set: LabelSet = (LabelName::new("label_name"), LabelValue::new("value")).into(); + + let mut metric_collection = + MetricCollection::new(MetricKindCollection::new(vec![]), MetricKindCollection::new(vec![])); + + metric_collection.describe_counter(&MetricName::new("test_counter"), None, None); + + assert_eq!( + metric_collection.get_counter_value(&MetricName::new("test_counter"), &label_set), + Counter::default() + ); + } + + #[test] + #[should_panic(expected = "Duplicate MetricName found in MetricKindCollection")] + fn it_should_not_allow_duplicate_metric_names_when_instantiating() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let label_set: LabelSet = (LabelName::new("label_name"), LabelValue::new("value")).into(); + + let _unused = MetricKindCollection::new(vec![ + Metric::new( + MetricName::new("test_counter"), + SampleCollection::new(vec![Sample::new(Counter::new(0), time, label_set.clone())]), + ), + Metric::new( + MetricName::new("test_counter"), + SampleCollection::new(vec![Sample::new(Counter::new(0), time, label_set.clone())]), + ), + ]); + } + } + + mod for_gauges { + + use pretty_assertions::assert_eq; + + use super::*; + use crate::label::{LabelName, LabelValue}; + use crate::sample::Sample; + + #[test] + fn it_should_set_a_preexistent_gauge() { + 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( + MetricKindCollection::new(vec![]), + MetricKindCollection::new(vec![Metric::new( + MetricName::new("test_gauge"), + SampleCollection::new(vec![Sample::new(Gauge::new(0.0), time, label_set.clone())]), + )]), + ); + + metric_collection.set_gauge(&MetricName::new("test_gauge"), &label_set, 1.0, time); + + assert_eq!( + metric_collection.get_gauge_value(&MetricName::new("test_gauge"), &label_set), + Gauge::new(1.0) + ); + } + + #[test] + fn it_should_automatically_create_a_gauge_when_setting_if_it_does_not_exist() { + 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(MetricKindCollection::new(vec![]), MetricKindCollection::new(vec![])); + + metric_collection.set_gauge(&MetricName::new("test_gauge"), &label_set, 1.0, time); + + assert_eq!( + metric_collection.get_gauge_value(&MetricName::new("test_gauge"), &label_set), + Gauge::new(1.0) + ); + } + + #[test] + fn it_should_allow_making_sure_a_gauge_exists_without_increasing_it() { + let label_set: LabelSet = (LabelName::new("label_name"), LabelValue::new("value")).into(); + + let mut metric_collection = + MetricCollection::new(MetricKindCollection::new(vec![]), MetricKindCollection::new(vec![])); + + metric_collection.ensure_gauge_exists(&MetricName::new("test_gauge")); + + assert_eq!( + metric_collection.get_gauge_value(&MetricName::new("test_gauge"), &label_set), + Gauge::default() + ); + } + + #[test] + fn it_should_allow_describing_a_gauge_before_using_it() { + let label_set: LabelSet = (LabelName::new("label_name"), LabelValue::new("value")).into(); + + let mut metric_collection = + MetricCollection::new(MetricKindCollection::new(vec![]), MetricKindCollection::new(vec![])); + + metric_collection.describe_gauge(&MetricName::new("test_gauge"), None, None); + + assert_eq!( + metric_collection.get_gauge_value(&MetricName::new("test_gauge"), &label_set), + Gauge::default() + ); + } + + #[test] + #[should_panic(expected = "Duplicate MetricName found in MetricKindCollection")] + fn it_should_not_allow_duplicate_metric_names_when_instantiating() { + let time = DurationSinceUnixEpoch::from_secs(1_743_552_000); + let label_set: LabelSet = (LabelName::new("label_name"), LabelValue::new("value")).into(); + + let _unused = MetricKindCollection::new(vec![ + Metric::new( + MetricName::new("test_gauge"), + SampleCollection::new(vec![Sample::new(Gauge::new(0.0), time, label_set.clone())]), + ), + Metric::new( + MetricName::new("test_gauge"), + SampleCollection::new(vec![Sample::new(Gauge::new(0.0), time, label_set.clone())]), + ), + ]); + } + } +} diff --git a/packages/metrics/src/prometheus.rs b/packages/metrics/src/prometheus.rs new file mode 100644 index 000000000..bf058e442 --- /dev/null +++ b/packages/metrics/src/prometheus.rs @@ -0,0 +1,15 @@ +pub trait PrometheusSerializable { + /// Convert the implementing type into a Prometheus exposition format string. + /// + /// # Returns + /// + /// A `String` containing the serialized representation. + fn to_prometheus(&self) -> String; +} + +// Blanket implementation for references +impl PrometheusSerializable for &T { + fn to_prometheus(&self) -> String { + (*self).to_prometheus() + } +} diff --git a/packages/metrics/src/sample.rs b/packages/metrics/src/sample.rs new file mode 100644 index 000000000..5567dffec --- /dev/null +++ b/packages/metrics/src/sample.rs @@ -0,0 +1,406 @@ +use chrono::{DateTime, Utc}; +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use super::counter::Counter; +use super::gauge::Gauge; +use super::label::LabelSet; +use super::prometheus::PrometheusSerializable; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Sample { + #[serde(flatten)] + measurement: Measurement, + + #[serde(rename = "labels")] + label_set: LabelSet, +} + +impl Sample { + #[must_use] + pub fn new(value: T, recorded_at: DurationSinceUnixEpoch, label_set: LabelSet) -> Self { + let data = Measurement { value, recorded_at }; + + Self { + measurement: data, + label_set, + } + } + + #[must_use] + pub fn measurement(&self) -> &Measurement { + &self.measurement + } + + #[must_use] + pub fn value(&self) -> &T { + &self.measurement.value + } + + #[must_use] + pub fn recorded_at(&self) -> DurationSinceUnixEpoch { + self.measurement.recorded_at + } + + #[must_use] + pub fn labels(&self) -> &LabelSet { + &self.label_set + } +} + +impl PrometheusSerializable for Sample { + fn to_prometheus(&self) -> String { + format!("{} {}", self.label_set.to_prometheus(), self.measurement.to_prometheus()) + } +} + +impl Sample { + pub fn increment(&mut self, time: DurationSinceUnixEpoch) { + self.measurement.increment(time); + } +} + +impl Sample { + pub fn set(&mut self, value: f64, time: DurationSinceUnixEpoch) { + self.measurement.set(value, time); + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Measurement { + /// The value of the sample. + value: T, + + /// The time when the sample was last updated. + #[serde(serialize_with = "serialize_duration", deserialize_with = "deserialize_duration")] + recorded_at: DurationSinceUnixEpoch, +} + +impl Measurement { + #[must_use] + pub fn new(value: T, recorded_at: DurationSinceUnixEpoch) -> Self { + Self { value, recorded_at } + } + + #[must_use] + pub fn value(&self) -> &T { + &self.value + } + + #[must_use] + pub fn recorded_at(&self) -> DurationSinceUnixEpoch { + self.recorded_at + } + + fn set_recorded_at(&mut self, time: DurationSinceUnixEpoch) { + self.recorded_at = time; + } +} + +impl From> for (LabelSet, Measurement) { + fn from(sample: Sample) -> Self { + (sample.label_set, sample.measurement) + } +} + +impl PrometheusSerializable for Measurement { + fn to_prometheus(&self) -> String { + self.value.to_prometheus() + } +} + +impl Measurement { + pub fn increment(&mut self, time: DurationSinceUnixEpoch) { + self.value.increment(1); + self.set_recorded_at(time); + } +} + +impl Measurement { + pub fn set(&mut self, value: f64, time: DurationSinceUnixEpoch) { + self.value.set(value); + self.set_recorded_at(time); + } +} + +/// Serializes the `recorded_at` field as a string in ISO 8601 format (RFC 3339). +/// +/// # Errors +/// +/// Returns an error if: +/// - The conversion from `u64` to `i64` fails. +/// - The timestamp is invalid. +fn serialize_duration(duration: &DurationSinceUnixEpoch, serializer: S) -> Result +where + S: Serializer, +{ + let secs = i64::try_from(duration.as_secs()).map_err(|_| serde::ser::Error::custom("Timestamp too large"))?; + let nanos = duration.subsec_nanos(); + + let datetime = DateTime::from_timestamp(secs, nanos).ok_or_else(|| serde::ser::Error::custom("Invalid timestamp"))?; + + serializer.serialize_str(&datetime.to_rfc3339()) // Serializes as ISO 8601 (RFC 3339) +} + +fn deserialize_duration<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + // Deserialize theISO 8601 (RFC 3339) formatted string + let datetime_str = String::deserialize(deserializer)?; + + let datetime = + DateTime::parse_from_rfc3339(&datetime_str).map_err(|e| de::Error::custom(format!("Invalid datetime format: {e}")))?; + + let datetime_utc = datetime.with_timezone(&Utc); + + let secs = u64::try_from(datetime_utc.timestamp()).map_err(|_| de::Error::custom("Timestamp out of range"))?; + + Ok(DurationSinceUnixEpoch::new(secs, datetime_utc.timestamp_subsec_nanos())) +} + +#[cfg(test)] +mod tests { + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use super::*; + + // Helper function to create a sample update time. + fn updated_at_time() -> DurationSinceUnixEpoch { + DurationSinceUnixEpoch::from_secs(1_743_552_000) + } + + #[test] + fn it_should_have_a_value() { + let sample = Sample::new( + 42, + DurationSinceUnixEpoch::from_secs(1_743_552_000), + LabelSet::from(vec![("test", "label")]), + ); + + assert_eq!(sample.value(), &42); + } + + #[test] + fn it_should_record_the_latest_update_time() { + let sample = Sample::new( + 42, + DurationSinceUnixEpoch::from_secs(1_743_552_000), + LabelSet::from(vec![("test", "label")]), + ); + + assert_eq!(sample.recorded_at(), updated_at_time()); + } + + #[test] + fn it_should_include_a_label_set() { + let sample = Sample::new( + 42, + DurationSinceUnixEpoch::from_secs(1_743_552_000), + LabelSet::from(vec![("test", "label")]), + ); + + assert_eq!(sample.labels(), &LabelSet::from(vec![("test", "label")])); + } + + mod for_counter_type_sample { + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use crate::label::LabelSet; + use crate::prometheus::PrometheusSerializable; + use crate::sample::tests::updated_at_time; + use crate::sample::{Counter, Sample}; + + #[test] + fn it_should_allow_a_counter_type_value() { + let sample = Sample::new( + Counter::new(42), + DurationSinceUnixEpoch::from_secs(1_743_552_000), + LabelSet::from(vec![("label_name", "label vale")]), + ); + + assert_eq!(sample.value(), &Counter::new(42)); + } + + #[test] + fn it_should_allow_incrementing_the_counter() { + let mut sample = Sample::new(Counter::default(), DurationSinceUnixEpoch::default(), LabelSet::default()); + + sample.increment(updated_at_time()); + + assert_eq!(sample.value(), &Counter::new(1)); + } + + #[test] + fn it_should_record_the_latest_update_time_when_the_counter_is_incremented() { + let mut sample = Sample::new(Counter::default(), DurationSinceUnixEpoch::default(), LabelSet::default()); + + let time = updated_at_time(); + + sample.increment(time); + + assert_eq!(sample.recorded_at(), time); + } + + #[test] + fn it_should_allow_exporting_to_prometheus_format() { + let counter = Counter::new(42); + + let labels = LabelSet::from(vec![("label_name", "label_value"), ("method", "GET")]); + + let sample = Sample::new(counter, DurationSinceUnixEpoch::default(), labels); + + assert_eq!(sample.to_prometheus(), r#"{label_name="label_value",method="GET"} 42"#); + } + } + mod for_gauge_type_sample { + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use crate::label::LabelSet; + use crate::prometheus::PrometheusSerializable; + use crate::sample::tests::updated_at_time; + use crate::sample::{Gauge, Sample}; + + #[test] + fn it_should_allow_a_counter_type_value() { + let sample = Sample::new( + Gauge::new(42.0), + DurationSinceUnixEpoch::from_secs(1_743_552_000), + LabelSet::from(vec![("label_name", "label vale")]), + ); + + assert_eq!(sample.value(), &Gauge::new(42.0)); + } + + #[test] + fn it_should_allow_incrementing_the_counter() { + let mut sample = Sample::new(Gauge::default(), DurationSinceUnixEpoch::default(), LabelSet::default()); + + sample.set(1.0, updated_at_time()); + + assert_eq!(sample.value(), &Gauge::new(1.0)); + } + + #[test] + fn it_should_record_the_latest_update_time_when_the_counter_is_incremented() { + let mut sample = Sample::new(Gauge::default(), DurationSinceUnixEpoch::default(), LabelSet::default()); + + let time = updated_at_time(); + + sample.set(1.0, time); + + assert_eq!(sample.recorded_at(), time); + } + + #[test] + fn it_should_allow_exporting_to_prometheus_format() { + let counter = Gauge::new(42.0); + + let labels = LabelSet::from(vec![("label_name", "label_value"), ("method", "GET")]); + + let sample = Sample::new(counter, DurationSinceUnixEpoch::default(), labels); + + assert_eq!(sample.to_prometheus(), r#"{label_name="label_value",method="GET"} 42"#); + } + } + + mod serialization_to_json { + use pretty_assertions::assert_eq; + use serde_json::json; + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use crate::label::LabelSet; + use crate::sample::tests::updated_at_time; + use crate::sample::Sample; + + #[test] + fn test_serialization_round_trip() { + let original = Sample::new(42, updated_at_time(), LabelSet::from(vec![("test", "serialization")])); + + let json = serde_json::to_string(&original).unwrap(); + let deserialized: Sample = serde_json::from_str(&json).unwrap(); + + assert_eq!(original.measurement.value, deserialized.measurement.value); + assert_eq!(original.measurement.recorded_at, deserialized.measurement.recorded_at); + assert_eq!(original.label_set, deserialized.label_set); + } + + #[test] + fn test_rfc3339_serialization_format_for_update_time() { + let sample = Sample::new( + 42, + DurationSinceUnixEpoch::new(1_743_552_000, 100), + LabelSet::from(vec![("label_name", "label value")]), + ); + + let json = serde_json::to_string(&sample).unwrap(); + + let expected_json = r#" + { + "value": 42, + "recorded_at": "2025-04-02T00:00:00.000000100+00:00", + "labels": [ + { + "name": "label_name", + "value": "label value" + } + ] + } + "#; + + assert_eq!( + serde_json::from_str::(&json).unwrap(), + serde_json::from_str::(expected_json).unwrap() + ); + } + + #[test] + fn test_invalid_update_timestamp_serialization() { + let timestamp_too_large = DurationSinceUnixEpoch::new(i64::MAX as u64 + 1, 0); + + let sample = Sample::new(42, timestamp_too_large, LabelSet::from(vec![("label_name", "label value")])); + + let result = serde_json::to_string(&sample); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Timestamp too large")); + } + + #[test] + fn test_invalid_update_datetime_deserialization() { + let invalid_json = json!( + r#" + { + "value": 42, + "recorded_at": "1-1-2023T25:00:00Z", + "labels": [ + { + "name": "label_name", + "value": "label value" + } + ] + } + "# + ); + + let result: Result = serde_json::from_value(invalid_json); + + assert!(result.unwrap_err().to_string().contains("invalid type")); + } + + #[test] + fn test_update_datetime_high_precision_nanoseconds() { + let sample = Sample::new( + 42, + DurationSinceUnixEpoch::new(1_743_552_000, 100), + LabelSet::from(vec![("label_name", "label value")]), + ); + + let json = serde_json::to_string(&sample).unwrap(); + + let deserialized: Sample = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized, sample); + } + } +} diff --git a/packages/metrics/src/sample_collection.rs b/packages/metrics/src/sample_collection.rs new file mode 100644 index 000000000..436a4bc7d --- /dev/null +++ b/packages/metrics/src/sample_collection.rs @@ -0,0 +1,415 @@ +use std::collections::hash_map::Iter; +use std::collections::{HashMap, HashSet}; +use std::fmt::Write as _; + +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + +use super::counter::Counter; +use super::gauge::Gauge; +use super::label::LabelSet; +use super::prometheus::PrometheusSerializable; +use super::sample::Sample; +use crate::sample::Measurement; + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct SampleCollection { + samples: HashMap>, +} + +impl SampleCollection { + /// # Panics + /// + /// Panics if there are duplicate `LabelSets` in the provided samples. + #[must_use] + pub fn new(samples: Vec>) -> Self { + let mut map: HashMap> = HashMap::with_capacity(samples.len()); + + for sample in samples { + let (label_set, sample_data): (LabelSet, Measurement) = sample.into(); + + assert!( + map.insert(label_set, sample_data).is_none(), + "Duplicate LabelSet found in SampleCollection" + ); + } + + Self { samples: map } + } + + #[must_use] + pub fn get(&self, label: &LabelSet) -> Option<&Measurement> { + self.samples.get(label) + } + + #[must_use] + pub fn len(&self) -> usize { + self.samples.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.samples.is_empty() + } + + #[must_use] + #[allow(clippy::iter_without_into_iter)] + pub fn iter(&self) -> Iter<'_, LabelSet, Measurement> { + self.samples.iter() + } +} + +impl SampleCollection { + pub fn increment(&mut self, label_set: &LabelSet, time: DurationSinceUnixEpoch) { + let sample = self + .samples + .entry(label_set.clone()) + .or_insert_with(|| Measurement::new(Counter::default(), time)); + + sample.increment(time); + } +} + +impl SampleCollection { + pub fn set(&mut self, label_set: &LabelSet, value: f64, time: DurationSinceUnixEpoch) { + let sample = self + .samples + .entry(label_set.clone()) + .or_insert_with(|| Measurement::new(Gauge::default(), time)); + + sample.set(value, time); + } +} + +impl Serialize for SampleCollection { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut samples: Vec> = vec![]; + + for (label_set, sample_data) in &self.samples { + samples.push(Sample::new(sample_data.value(), sample_data.recorded_at(), label_set.clone())); + } + + samples.serialize(serializer) + } +} + +impl<'de, T> Deserialize<'de> for SampleCollection +where + T: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + // First deserialize into a temporary Vec + let samples = Vec::>::deserialize(deserializer)?; + + // Check for duplicate label sets + let mut seen_labels = HashSet::new(); + + for sample in &samples { + if !seen_labels.insert(sample.labels()) { + return Err(de::Error::custom(format!("Duplicate label set found: {}", sample.labels()))); + } + } + + // Convert to HashMap-based storage + Ok(SampleCollection::new(samples)) + } +} + +impl PrometheusSerializable for SampleCollection { + fn to_prometheus(&self) -> String { + let mut output = String::new(); + + for (label_set, sample_data) in &self.samples { + let _ = write!(output, "{} {}", label_set.to_prometheus(), sample_data.to_prometheus()); + } + + output + } +} + +#[cfg(test)] +mod tests { + use torrust_tracker_primitives::DurationSinceUnixEpoch; + + use crate::counter::Counter; + use crate::label::LabelSet; + use crate::prometheus::PrometheusSerializable; + use crate::sample::Sample; + use crate::sample_collection::SampleCollection; + use crate::tests::format_prometheus_output; + + fn sample_update_time() -> DurationSinceUnixEpoch { + DurationSinceUnixEpoch::from_secs(1_743_552_000) + } + + #[test] + #[should_panic(expected = "Duplicate LabelSet found in SampleCollection")] + fn it_should_fail_trying_to_create_a_sample_collection_with_duplicate_label_sets() { + let samples = vec![ + Sample::new(Counter::default(), sample_update_time(), LabelSet::default()), + Sample::new(Counter::default(), sample_update_time(), LabelSet::default()), + ]; + + let _unused = SampleCollection::new(samples); + } + + #[test] + fn it_should_return_a_sample_searching_by_label_set_with_one_empty_label_set() { + let label_set = LabelSet::default(); + + let sample = Sample::new(Counter::default(), sample_update_time(), label_set.clone()); + + let collection = SampleCollection::new(vec![sample.clone()]); + + let retrieved = collection.get(&label_set); + + assert_eq!(retrieved.unwrap(), sample.measurement()); + } + + #[test] + fn it_should_return_a_sample_searching_by_label_set_with_two_label_sets() { + let label_set_1 = LabelSet::from(vec![("label_name_1", "label value 1")]); + let label_set_2 = LabelSet::from(vec![("label_name_2", "label value 2")]); + + let sample_1 = Sample::new(Counter::new(1), sample_update_time(), label_set_1.clone()); + let sample_2 = Sample::new(Counter::new(2), sample_update_time(), label_set_2.clone()); + + let collection = SampleCollection::new(vec![sample_1.clone(), sample_2.clone()]); + + let retrieved = collection.get(&label_set_1); + assert_eq!(retrieved.unwrap(), sample_1.measurement()); + + let retrieved = collection.get(&label_set_2); + assert_eq!(retrieved.unwrap(), sample_2.measurement()); + } + + #[test] + fn it_should_return_the_number_of_samples_in_the_collection() { + let samples = vec![Sample::new(Counter::default(), sample_update_time(), LabelSet::default())]; + let collection = SampleCollection::new(samples); + assert_eq!(collection.len(), 1); + } + + #[test] + fn it_should_return_zero_number_of_samples_when_empty() { + let empty = SampleCollection::::default(); + assert_eq!(empty.len(), 0); + } + + #[test] + fn it_should_indicate_is_it_is_empty() { + let empty = SampleCollection::::default(); + assert!(empty.is_empty()); + + let samples = vec![Sample::new(Counter::default(), sample_update_time(), LabelSet::default())]; + let collection = SampleCollection::new(samples); + assert!(!collection.is_empty()); + } + + #[test] + fn it_should_be_serializable_and_deserializable_for_json_format() { + let sample = Sample::new(Counter::default(), sample_update_time(), LabelSet::default()); + let collection = SampleCollection::new(vec![sample]); + + let serialized = serde_json::to_string(&collection).unwrap(); + let deserialized: SampleCollection = serde_json::from_str(&serialized).unwrap(); + + assert_eq!(deserialized, collection); + } + + #[test] + fn it_should_fail_deserializing_from_json_with_duplicate_label_sets() { + let samples = vec![ + Sample::new(Counter::default(), sample_update_time(), LabelSet::default()), + Sample::new(Counter::default(), sample_update_time(), LabelSet::default()), + ]; + + let serialized = serde_json::to_string(&samples).unwrap(); + + let result: Result, _> = serde_json::from_str(&serialized); + + assert!(result.is_err()); + } + + #[test] + fn it_should_be_exportable_to_prometheus_format_when_empty() { + let sample = Sample::new(Counter::default(), sample_update_time(), LabelSet::default()); + let collection = SampleCollection::new(vec![sample]); + + let prometheus_output = collection.to_prometheus(); + + assert!(!prometheus_output.is_empty()); + } + + #[test] + fn it_should_be_exportable_to_prometheus_format() { + let sample = Sample::new( + Counter::new(1), + sample_update_time(), + LabelSet::from(vec![("labe_name_1", "label value value 1")]), + ); + + let collection = SampleCollection::new(vec![sample]); + + let prometheus_output = collection.to_prometheus(); + + let expected_prometheus_output = format_prometheus_output("{labe_name_1=\"label value value 1\"} 1"); + + assert_eq!(prometheus_output, expected_prometheus_output); + } + + #[cfg(test)] + mod for_counters { + + use std::ops::Add; + + use super::super::LabelSet; + use super::*; + + #[test] + fn it_should_increment_the_counter_for_a_preexisting_label_set() { + let label_set = LabelSet::default(); + let mut collection = SampleCollection::default(); + + // Initialize the sample + collection.increment(&label_set, sample_update_time()); + + // Verify initial state + let sample = collection.get(&label_set).unwrap(); + assert_eq!(sample.value(), &Counter::new(1)); + + // Increment again + collection.increment(&label_set, sample_update_time()); + let sample = collection.get(&label_set).unwrap(); + assert_eq!(*sample.value(), Counter::new(2)); + } + + #[test] + fn it_should_allow_increment_the_counter_for_a_non_existent_label_set() { + let label_set = LabelSet::default(); + let mut collection = SampleCollection::default(); + + // Increment a non-existent label + collection.increment(&label_set, sample_update_time()); + + // Verify the label exists + assert!(collection.get(&label_set).is_some()); + let sample = collection.get(&label_set).unwrap(); + assert_eq!(*sample.value(), Counter::new(1)); + } + + #[test] + fn it_should_update_the_latest_update_time_when_incremented() { + let label_set = LabelSet::default(); + let initial_time = sample_update_time(); + + let mut collection = SampleCollection::default(); + collection.increment(&label_set, initial_time); + + // Increment with a new time + let new_time = initial_time.add(DurationSinceUnixEpoch::from_secs(1)); + collection.increment(&label_set, new_time); + + let sample = collection.get(&label_set).unwrap(); + assert_eq!(sample.recorded_at(), new_time); + assert_eq!(*sample.value(), Counter::new(2)); + } + + #[test] + fn it_should_increment_the_counter_for_multiple_labels() { + let label1 = LabelSet::from([("name", "value1")]); + let label2 = LabelSet::from([("name", "value2")]); + let now = sample_update_time(); + + let mut collection = SampleCollection::default(); + + collection.increment(&label1, now); + collection.increment(&label2, now); + + assert_eq!(collection.get(&label1).unwrap().value(), &Counter::new(1)); + assert_eq!(collection.get(&label2).unwrap().value(), &Counter::new(1)); + assert_eq!(collection.len(), 2); + } + } + + #[cfg(test)] + mod for_gauges { + + use std::ops::Add; + + use super::super::LabelSet; + use super::*; + use crate::gauge::Gauge; + + #[test] + fn it_should_increment_the_gauge_for_a_preexisting_label_set() { + let label_set = LabelSet::default(); + let mut collection = SampleCollection::default(); + + // Initialize the sample + collection.set(&label_set, 1.0, sample_update_time()); + + // Verify initial state + let sample = collection.get(&label_set).unwrap(); + assert_eq!(sample.value(), &Gauge::new(1.0)); + + // Set again + collection.set(&label_set, 2.0, sample_update_time()); + let sample = collection.get(&label_set).unwrap(); + assert_eq!(*sample.value(), Gauge::new(2.0)); + } + + #[test] + fn it_should_allow_increment_the_gauge_for_a_non_existent_label_set() { + let label_set = LabelSet::default(); + let mut collection = SampleCollection::default(); + + // Set a non-existent label + collection.set(&label_set, 1.0, sample_update_time()); + + // Verify the label exists + assert!(collection.get(&label_set).is_some()); + let sample = collection.get(&label_set).unwrap(); + assert_eq!(*sample.value(), Gauge::new(1.0)); + } + + #[test] + fn it_should_update_the_latest_update_time_when_incremented() { + let label_set = LabelSet::default(); + let initial_time = sample_update_time(); + + let mut collection = SampleCollection::default(); + collection.set(&label_set, 1.0, initial_time); + + // Set with a new time + let new_time = initial_time.add(DurationSinceUnixEpoch::from_secs(1)); + collection.set(&label_set, 2.0, new_time); + + let sample = collection.get(&label_set).unwrap(); + assert_eq!(sample.recorded_at(), new_time); + assert_eq!(*sample.value(), Gauge::new(2.0)); + } + + #[test] + fn it_should_increment_the_gauge_for_multiple_labels() { + let label1 = LabelSet::from([("name", "value1")]); + let label2 = LabelSet::from([("name", "value2")]); + let now = sample_update_time(); + + let mut collection = SampleCollection::default(); + + collection.set(&label1, 1.0, now); + collection.set(&label2, 2.0, now); + + assert_eq!(collection.get(&label1).unwrap().value(), &Gauge::new(1.0)); + assert_eq!(collection.get(&label2).unwrap().value(), &Gauge::new(2.0)); + assert_eq!(collection.len(), 2); + } + } +} diff --git a/packages/metrics/src/unit.rs b/packages/metrics/src/unit.rs new file mode 100644 index 000000000..b98e6836d --- /dev/null +++ b/packages/metrics/src/unit.rs @@ -0,0 +1,25 @@ +//! This module defines the `Unit` enum, which represents various units of +//! measurement. +//! +//! The `Unit` enum is used to specify the unit of measurement for metrics. +//! +//! They were copied from the `metrics` crate, to allow future compatibility. +pub enum Unit { + Count, + Percent, + Seconds, + Milliseconds, + Microseconds, + Nanoseconds, + Tebibytes, + Gibibytes, + Mebibytes, + Kibibytes, + Bytes, + TerabitsPerSecond, + GigabitsPerSecond, + MegabitsPerSecond, + KilobitsPerSecond, + BitsPerSecond, + CountPerSecond, +} diff --git a/packages/primitives/src/service_binding.rs b/packages/primitives/src/service_binding.rs index dbbb32fd5..30eb1aa9e 100644 --- a/packages/primitives/src/service_binding.rs +++ b/packages/primitives/src/service_binding.rs @@ -83,6 +83,12 @@ impl ServiceBinding { Ok(Self { protocol, bind_address }) } + /// Returns the protocol used by the service. + #[must_use] + pub fn protocol(&self) -> Protocol { + self.protocol.clone() + } + #[must_use] pub fn bind_address(&self) -> SocketAddr { self.bind_address diff --git a/packages/rest-tracker-api-core/Cargo.toml b/packages/rest-tracker-api-core/Cargo.toml index d9ccb5d3f..0077572fb 100644 --- a/packages/rest-tracker-api-core/Cargo.toml +++ b/packages/rest-tracker-api-core/Cargo.toml @@ -19,6 +19,7 @@ bittorrent-tracker-core = { version = "3.0.0-develop", path = "../tracker-core" bittorrent-udp-tracker-core = { version = "3.0.0-develop", path = "../udp-tracker-core" } tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } +torrust-tracker-metrics = { version = "3.0.0-develop", path = "../metrics" } torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } torrust-udp-tracker-server = { version = "3.0.0-develop", path = "../udp-tracker-server" } diff --git a/packages/rest-tracker-api-core/src/statistics/services.rs b/packages/rest-tracker-api-core/src/statistics/services.rs index c40f7c82e..9277df92b 100644 --- a/packages/rest-tracker-api-core/src/statistics/services.rs +++ b/packages/rest-tracker-api-core/src/statistics/services.rs @@ -4,6 +4,7 @@ use bittorrent_tracker_core::torrent::repository::in_memory::InMemoryTorrentRepo use bittorrent_udp_tracker_core::services::banning::BanService; use bittorrent_udp_tracker_core::{self}; use tokio::sync::RwLock; +use torrust_tracker_metrics::metric_collection::MetricCollection; use torrust_tracker_primitives::swarm_metadata::AggregateSwarmMetadata; use torrust_udp_tracker_server::statistics as udp_server_statistics; @@ -77,6 +78,47 @@ pub async fn get_metrics( } } +#[derive(Debug, PartialEq)] +pub struct TrackerLabeledMetrics { + pub metrics: MetricCollection, +} + +/// It returns all the [`TrackerLabeledMetrics`] +/// +/// # Panics +/// +/// Will panic if the metrics cannot be merged. This could happen if the +/// packages are producing duplicate metric names, for example. +#[allow(deprecated)] +pub async fn get_labeled_metrics( + in_memory_torrent_repository: Arc, + ban_service: Arc>, + http_stats_repository: Arc, + udp_stats_repository: Arc, + udp_server_stats_repository: Arc, +) -> TrackerLabeledMetrics { + let _torrents_metrics = in_memory_torrent_repository.get_torrents_metrics(); + let _udp_banned_ips_total = ban_service.read().await.get_banned_ips_total(); + + let http_stats = http_stats_repository.get_stats().await; + let udp_stats_repository = udp_stats_repository.get_stats().await; + let udp_server_stats = udp_server_stats_repository.get_stats().await; + + // Merge all the metrics into a single collection + let mut metrics = MetricCollection::default(); + metrics + .merge(&http_stats.metric_collection) + .expect("msg: failed to merge HTTP core metrics"); + metrics + .merge(&udp_stats_repository.metric_collection) + .expect("failed to merge UDP core metrics"); + metrics + .merge(&udp_server_stats.metric_collection) + .expect("failed to merge UDP server metrics"); + + TrackerLabeledMetrics { metrics } +} + #[cfg(test)] mod tests { use std::sync::Arc; diff --git a/packages/udp-tracker-core/Cargo.toml b/packages/udp-tracker-core/Cargo.toml index 88bab51c1..0354777db 100644 --- a/packages/udp-tracker-core/Cargo.toml +++ b/packages/udp-tracker-core/Cargo.toml @@ -25,9 +25,12 @@ criterion = { version = "0.5.1", features = ["async_tokio"] } futures = "0" lazy_static = "1" rand = "0" +serde = "1.0.219" thiserror = "2" tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] } +torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } +torrust-tracker-metrics = { version = "3.0.0-develop", path = "../metrics" } torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } tracing = "0" zerocopy = "0.7" @@ -39,4 +42,3 @@ torrust-tracker-test-helpers = { version = "3.0.0-develop", path = "../test-help [[bench]] harness = false name = "udp_tracker_core_benchmark" - diff --git a/packages/udp-tracker-core/src/event/mod.rs b/packages/udp-tracker-core/src/event/mod.rs index e25f557e2..6cb43e5a1 100644 --- a/packages/udp-tracker-core/src/event/mod.rs +++ b/packages/udp-tracker-core/src/event/mod.rs @@ -1,5 +1,6 @@ use std::net::SocketAddr; +use torrust_tracker_metrics::label::{LabelName, LabelSet, LabelValue}; use torrust_tracker_primitives::service_binding::ServiceBinding; pub mod sender; @@ -37,3 +38,22 @@ impl ConnectionContext { self.server_service_binding.bind_address() } } + +impl From for LabelSet { + fn from(connection_context: ConnectionContext) -> Self { + LabelSet::from([ + ( + LabelName::new("server_binding_protocol"), + LabelValue::new(&connection_context.server_service_binding.protocol().to_string()), + ), + ( + LabelName::new("server_binding_ip"), + LabelValue::new(&connection_context.server_service_binding.bind_address().ip().to_string()), + ), + ( + LabelName::new("server_binding_port"), + LabelValue::new(&connection_context.server_service_binding.bind_address().port().to_string()), + ), + ]) + } +} diff --git a/packages/udp-tracker-core/src/lib.rs b/packages/udp-tracker-core/src/lib.rs index 94ce93068..8e937e79c 100644 --- a/packages/udp-tracker-core/src/lib.rs +++ b/packages/udp-tracker-core/src/lib.rs @@ -5,6 +5,19 @@ pub mod event; pub mod services; pub mod statistics; +use torrust_tracker_clock::clock; + +/// This code needs to be copied into each crate. +/// Working version, for production. +#[cfg(not(test))] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Working; + +/// Stopped version, for testing. +#[cfg(test)] +#[allow(dead_code)] +pub(crate) type CurrentClock = clock::Stopped; + use crypto::ephemeral_instance_keys; use tracing::instrument; diff --git a/packages/udp-tracker-core/src/statistics/event/handler.rs b/packages/udp-tracker-core/src/statistics/event/handler.rs index 3968ca4e7..a910d9373 100644 --- a/packages/udp-tracker-core/src/statistics/event/handler.rs +++ b/packages/udp-tracker-core/src/statistics/event/handler.rs @@ -1,35 +1,81 @@ +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + use crate::event::Event; use crate::statistics::repository::Repository; /// # Panics /// /// This function panics if the IP version does not match the event type. -pub async fn handle_event(event: Event, stats_repository: &Repository) { +pub async fn handle_event(event: Event, stats_repository: &Repository, now: DurationSinceUnixEpoch) { match event { - Event::UdpConnect { context } => match context.client_socket_addr.ip() { - std::net::IpAddr::V4(_) => { - stats_repository.increase_udp4_connections().await; - } - std::net::IpAddr::V6(_) => { - stats_repository.increase_udp6_connections().await; - } - }, - Event::UdpAnnounce { context } => match context.client_socket_addr.ip() { - std::net::IpAddr::V4(_) => { - stats_repository.increase_udp4_announces().await; - } - std::net::IpAddr::V6(_) => { - stats_repository.increase_udp6_announces().await; + Event::UdpConnect { context } => { + // Global fixed metrics + + match context.client_socket_addr.ip() { + std::net::IpAddr::V4(_) => { + stats_repository.increase_udp4_connections().await; + } + std::net::IpAddr::V6(_) => { + stats_repository.increase_udp6_connections().await; + } } - }, - Event::UdpScrape { context } => match context.client_socket_addr.ip() { - std::net::IpAddr::V4(_) => { - stats_repository.increase_udp4_scrapes().await; + + // Extendable metrics + + stats_repository + .increase_counter( + &MetricName::new("udp_tracker_core_connect_requests_received_total"), + &LabelSet::from(context), + now, + ) + .await; + } + Event::UdpAnnounce { context } => { + // Global fixed metrics + + match context.client_socket_addr.ip() { + std::net::IpAddr::V4(_) => { + stats_repository.increase_udp4_announces().await; + } + std::net::IpAddr::V6(_) => { + stats_repository.increase_udp6_announces().await; + } } - std::net::IpAddr::V6(_) => { - stats_repository.increase_udp6_scrapes().await; + + // Extendable metrics + + stats_repository + .increase_counter( + &MetricName::new("udp_tracker_core_announce_requests_received_total"), + &LabelSet::from(context), + now, + ) + .await; + } + Event::UdpScrape { context } => { + // Global fixed metrics + + match context.client_socket_addr.ip() { + std::net::IpAddr::V4(_) => { + stats_repository.increase_udp4_scrapes().await; + } + std::net::IpAddr::V6(_) => { + stats_repository.increase_udp6_scrapes().await; + } } - }, + + // Extendable metrics + + stats_repository + .increase_counter( + &MetricName::new("udp_tracker_core_scrape_requests_received_total"), + &LabelSet::from(context), + now, + ) + .await; + } } tracing::debug!("stats: {:?}", stats_repository.get_stats().await); @@ -39,11 +85,13 @@ pub async fn handle_event(event: Event, stats_repository: &Repository) { mod tests { use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + use torrust_tracker_clock::clock::Time; use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; use crate::event::{ConnectionContext, Event}; use crate::statistics::event::handler::handle_event; use crate::statistics::repository::Repository; + use crate::CurrentClock; #[tokio::test] async fn should_increase_the_udp4_connections_counter_when_it_receives_a_udp4_connect_event() { @@ -61,6 +109,7 @@ mod tests { ), }, &stats_repository, + CurrentClock::now(), ) .await; @@ -85,6 +134,7 @@ mod tests { ), }, &stats_repository, + CurrentClock::now(), ) .await; @@ -109,6 +159,7 @@ mod tests { ), }, &stats_repository, + CurrentClock::now(), ) .await; @@ -133,6 +184,7 @@ mod tests { ), }, &stats_repository, + CurrentClock::now(), ) .await; @@ -157,6 +209,7 @@ mod tests { ), }, &stats_repository, + CurrentClock::now(), ) .await; @@ -181,6 +234,7 @@ mod tests { ), }, &stats_repository, + CurrentClock::now(), ) .await; diff --git a/packages/udp-tracker-core/src/statistics/event/listener.rs b/packages/udp-tracker-core/src/statistics/event/listener.rs index f3afafc4f..8fc82fbcb 100644 --- a/packages/udp-tracker-core/src/statistics/event/listener.rs +++ b/packages/udp-tracker-core/src/statistics/event/listener.rs @@ -1,13 +1,15 @@ use tokio::sync::broadcast; +use torrust_tracker_clock::clock::Time; use super::handler::handle_event; use crate::event::Event; use crate::statistics::repository::Repository; +use crate::CurrentClock; pub async fn dispatch_events(mut receiver: broadcast::Receiver, stats_repository: Repository) { loop { match receiver.recv().await { - Ok(event) => handle_event(event, &stats_repository).await, + Ok(event) => handle_event(event, &stats_repository, CurrentClock::now()).await, Err(e) => { tracing::error!("Error receiving udp tracker core event: {:?}", e); break; diff --git a/packages/udp-tracker-core/src/statistics/metrics.rs b/packages/udp-tracker-core/src/statistics/metrics.rs index 1b3805288..23cec8036 100644 --- a/packages/udp-tracker-core/src/statistics/metrics.rs +++ b/packages/udp-tracker-core/src/statistics/metrics.rs @@ -1,3 +1,9 @@ +use serde::Serialize; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_metrics::metric_collection::MetricCollection; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + /// Metrics collected by the tracker. /// /// - Number of connections handled @@ -6,7 +12,7 @@ /// /// These metrics are collected for each connection type: UDP and HTTP /// and also for each IP version used by the peers: IPv4 and IPv6. -#[derive(Debug, PartialEq, Default)] +#[derive(Debug, PartialEq, Default, Serialize)] pub struct Metrics { /// Total number of UDP (UDP tracker) connections from IPv4 peers. pub udp4_connections_handled: u64, @@ -25,4 +31,17 @@ pub struct Metrics { /// Total number of UDP (UDP tracker) `scrape` requests from IPv6 peers. pub udp6_scrapes_handled: u64, + + /// A collection of metrics. + pub metric_collection: MetricCollection, +} + +impl Metrics { + pub fn increase_counter(&mut self, metric_name: &MetricName, labels: &LabelSet, now: DurationSinceUnixEpoch) { + self.metric_collection.increase_counter(metric_name, labels, now); + } + + pub fn set_gauge(&mut self, metric_name: &MetricName, labels: &LabelSet, value: f64, now: DurationSinceUnixEpoch) { + self.metric_collection.set_gauge(metric_name, labels, value, now); + } } diff --git a/packages/udp-tracker-core/src/statistics/mod.rs b/packages/udp-tracker-core/src/statistics/mod.rs index 939a41061..cdba76df3 100644 --- a/packages/udp-tracker-core/src/statistics/mod.rs +++ b/packages/udp-tracker-core/src/statistics/mod.rs @@ -4,3 +4,33 @@ pub mod metrics; pub mod repository; pub mod services; pub mod setup; + +use metrics::Metrics; +use torrust_tracker_metrics::metric::description::MetricDescription; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_metrics::unit::Unit; + +#[must_use] +pub fn describe_metrics() -> Metrics { + let mut metrics = Metrics::default(); + + metrics.metric_collection.describe_counter( + &MetricName::new("udp_tracker_core_connect_requests_received_total"), + Some(Unit::Count), + Some(MetricDescription::new("Total number of UDP connect requests received")), + ); + + metrics.metric_collection.describe_counter( + &MetricName::new("udp_tracker_core_announce_requests_received_total"), + Some(Unit::Count), + Some(MetricDescription::new("Total number of UDP announce requests received")), + ); + + metrics.metric_collection.describe_counter( + &MetricName::new("udp_tracker_core_scrape_requests_received_total"), + Some(Unit::Count), + Some(MetricDescription::new("Total number of UDP scrape requests received")), + ); + + metrics +} diff --git a/packages/udp-tracker-core/src/statistics/repository.rs b/packages/udp-tracker-core/src/statistics/repository.rs index f7609e5c2..49c91c751 100644 --- a/packages/udp-tracker-core/src/statistics/repository.rs +++ b/packages/udp-tracker-core/src/statistics/repository.rs @@ -1,7 +1,11 @@ use std::sync::Arc; use tokio::sync::{RwLock, RwLockReadGuard}; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_primitives::DurationSinceUnixEpoch; +use super::describe_metrics; use super::metrics::Metrics; /// A repository for the tracker metrics. @@ -20,7 +24,7 @@ impl Repository { #[must_use] pub fn new() -> Self { Self { - stats: Arc::new(RwLock::new(Metrics::default())), + stats: Arc::new(RwLock::new(describe_metrics())), } } @@ -63,4 +67,10 @@ impl Repository { stats_lock.udp6_scrapes_handled += 1; drop(stats_lock); } + + pub async fn increase_counter(&self, metric_name: &MetricName, labels: &LabelSet, now: DurationSinceUnixEpoch) { + let mut stats_lock = self.stats.write().await; + stats_lock.increase_counter(metric_name, labels, now); + drop(stats_lock); + } } diff --git a/packages/udp-tracker-core/src/statistics/services.rs b/packages/udp-tracker-core/src/statistics/services.rs index d3c1d4710..d9b016b0d 100644 --- a/packages/udp-tracker-core/src/statistics/services.rs +++ b/packages/udp-tracker-core/src/statistics/services.rs @@ -77,6 +77,8 @@ pub async fn get_metrics( udp6_connections_handled: stats.udp6_connections_handled, udp6_announces_handled: stats.udp6_announces_handled, udp6_scrapes_handled: stats.udp6_scrapes_handled, + // Extendable metrics + metric_collection: stats.metric_collection.clone(), }, } } @@ -91,7 +93,7 @@ mod tests { use torrust_tracker_primitives::swarm_metadata::AggregateSwarmMetadata; use torrust_tracker_test_helpers::configuration; - use crate::statistics; + use crate::statistics::describe_metrics; use crate::statistics::services::{get_metrics, TrackerMetrics}; pub fn tracker_configuration() -> Configuration { @@ -114,7 +116,7 @@ mod tests { tracker_metrics, TrackerMetrics { torrents_metrics: AggregateSwarmMetadata::default(), - protocol_metrics: statistics::metrics::Metrics::default(), + protocol_metrics: describe_metrics(), } ); } diff --git a/packages/udp-tracker-server/Cargo.toml b/packages/udp-tracker-server/Cargo.toml index f8fcd2def..23719d141 100644 --- a/packages/udp-tracker-server/Cargo.toml +++ b/packages/udp-tracker-server/Cargo.toml @@ -23,12 +23,14 @@ derive_more = { version = "2", features = ["as_ref", "constructor", "from"] } futures = "0" futures-util = "0" ringbuf = "0" +serde = "1.0.219" thiserror = "2" tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync"] } torrust-server-lib = { version = "3.0.0-develop", path = "../server-lib" } torrust-tracker-clock = { version = "3.0.0-develop", path = "../clock" } torrust-tracker-configuration = { version = "3.0.0-develop", path = "../configuration" } torrust-tracker-located-error = { version = "3.0.0-develop", path = "../located-error" } +torrust-tracker-metrics = { version = "3.0.0-develop", path = "../metrics" } torrust-tracker-primitives = { version = "3.0.0-develop", path = "../primitives" } tracing = "0" url = { version = "2", features = ["serde"] } diff --git a/packages/udp-tracker-server/src/event/mod.rs b/packages/udp-tracker-server/src/event/mod.rs index 68f07cfd6..316e1a414 100644 --- a/packages/udp-tracker-server/src/event/mod.rs +++ b/packages/udp-tracker-server/src/event/mod.rs @@ -1,6 +1,8 @@ +use std::fmt; use std::net::SocketAddr; use std::time::Duration; +use torrust_tracker_metrics::label::{LabelName, LabelSet, LabelValue}; use torrust_tracker_primitives::service_binding::ServiceBinding; pub mod sender; @@ -38,6 +40,17 @@ pub enum UdpRequestKind { Scrape, } +impl fmt::Display for UdpRequestKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let proto_str = match self { + UdpRequestKind::Connect => "connect", + UdpRequestKind::Announce => "announce", + UdpRequestKind::Scrape => "scrape", + }; + write!(f, "{proto_str}") + } +} + #[derive(Debug, PartialEq, Eq, Clone)] pub enum UdpResponseKind { Ok { @@ -76,3 +89,22 @@ impl ConnectionContext { self.server_service_binding.bind_address() } } + +impl From for LabelSet { + fn from(connection_context: ConnectionContext) -> Self { + LabelSet::from([ + ( + LabelName::new("server_binding_protocol"), + LabelValue::new(&connection_context.server_service_binding.protocol().to_string()), + ), + ( + LabelName::new("server_binding_ip"), + LabelValue::new(&connection_context.server_service_binding.bind_address().ip().to_string()), + ), + ( + LabelName::new("server_binding_port"), + LabelValue::new(&connection_context.server_service_binding.bind_address().port().to_string()), + ), + ]) + } +} diff --git a/packages/udp-tracker-server/src/statistics/event/handler.rs b/packages/udp-tracker-server/src/statistics/event/handler.rs index b06c8d725..91f5cef0c 100644 --- a/packages/udp-tracker-server/src/statistics/event/handler.rs +++ b/packages/udp-tracker-server/src/statistics/event/handler.rs @@ -1,3 +1,7 @@ +use torrust_tracker_metrics::label::{LabelName, LabelSet, LabelValue}; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + use crate::event::{Event, UdpRequestKind, UdpResponseKind}; use crate::statistics::repository::Repository; @@ -6,53 +10,103 @@ use crate::statistics::repository::Repository; /// This function panics if the client IP version does not match the expected /// version. #[allow(clippy::too_many_lines)] -pub async fn handle_event(event: Event, stats_repository: &Repository) { +pub async fn handle_event(event: Event, stats_repository: &Repository, now: DurationSinceUnixEpoch) { match event { - Event::UdpRequestAborted { .. } => { + Event::UdpRequestAborted { context } => { + // Global fixed metrics stats_repository.increase_udp_requests_aborted().await; + + // Extendable metrics + stats_repository + .increase_counter( + &MetricName::new("udp_tracker_server_requests_aborted_total"), + &LabelSet::from(context), + now, + ) + .await; } - Event::UdpRequestBanned { .. } => { + Event::UdpRequestBanned { context } => { + // Global fixed metrics stats_repository.increase_udp_requests_banned().await; + + // Extendable metrics + stats_repository + .increase_counter( + &MetricName::new("udp_tracker_server_requests_banned_total"), + &LabelSet::from(context), + now, + ) + .await; } - Event::UdpRequestReceived { context } => match context.client_socket_addr().ip() { - std::net::IpAddr::V4(_) => { - stats_repository.increase_udp4_requests().await; - } - std::net::IpAddr::V6(_) => { - stats_repository.increase_udp6_requests().await; - } - }, - Event::UdpRequestAccepted { context, kind } => match kind { - UdpRequestKind::Connect => match context.client_socket_addr().ip() { - std::net::IpAddr::V4(_) => { - stats_repository.increase_udp4_connections().await; - } - std::net::IpAddr::V6(_) => { - stats_repository.increase_udp6_connections().await; - } - }, - UdpRequestKind::Announce => match context.client_socket_addr().ip() { - std::net::IpAddr::V4(_) => { - stats_repository.increase_udp4_announces().await; - } - std::net::IpAddr::V6(_) => { - stats_repository.increase_udp6_announces().await; - } - }, - UdpRequestKind::Scrape => match context.client_socket_addr().ip() { + Event::UdpRequestReceived { context } => { + // Global fixed metrics + match context.client_socket_addr().ip() { std::net::IpAddr::V4(_) => { - stats_repository.increase_udp4_scrapes().await; + stats_repository.increase_udp4_requests().await; } std::net::IpAddr::V6(_) => { - stats_repository.increase_udp6_scrapes().await; + stats_repository.increase_udp6_requests().await; } - }, - }, + } + + // Extendable metrics + stats_repository + .increase_counter( + &MetricName::new("udp_tracker_server_requests_received_total"), + &LabelSet::from(context), + now, + ) + .await; + } + Event::UdpRequestAccepted { context, kind } => { + // Global fixed metrics + match kind { + UdpRequestKind::Connect => match context.client_socket_addr().ip() { + std::net::IpAddr::V4(_) => { + stats_repository.increase_udp4_connections().await; + } + std::net::IpAddr::V6(_) => { + stats_repository.increase_udp6_connections().await; + } + }, + UdpRequestKind::Announce => match context.client_socket_addr().ip() { + std::net::IpAddr::V4(_) => { + stats_repository.increase_udp4_announces().await; + } + std::net::IpAddr::V6(_) => { + stats_repository.increase_udp6_announces().await; + } + }, + UdpRequestKind::Scrape => match context.client_socket_addr().ip() { + std::net::IpAddr::V4(_) => { + stats_repository.increase_udp4_scrapes().await; + } + std::net::IpAddr::V6(_) => { + stats_repository.increase_udp6_scrapes().await; + } + }, + } + + // Extendable metrics + + let mut label_set = LabelSet::from(context); + + label_set.upsert(LabelName::new("kind"), LabelValue::new(&kind.to_string())); + + stats_repository + .increase_counter( + &MetricName::new("udp_tracker_server_requests_accepted_total"), + &label_set, + now, + ) + .await; + } Event::UdpResponseSent { context, kind, req_processing_time, } => { + // Global fixed metrics match context.client_socket_addr().ip() { std::net::IpAddr::V4(_) => { stats_repository.increase_udp4_responses().await; @@ -62,35 +116,94 @@ pub async fn handle_event(event: Event, stats_repository: &Repository) { } } - match kind { + let (result_label_value, kind_label_value) = match kind { UdpResponseKind::Ok { req_kind } => match req_kind { UdpRequestKind::Connect => { - stats_repository + let new_avg = stats_repository .recalculate_udp_avg_connect_processing_time_ns(req_processing_time) .await; + + // Extendable metrics + stats_repository + .set_gauge( + &MetricName::new("udp_tracker_server_performance_avg_connect_processing_time_ns"), + &LabelSet::from(context.clone()), + new_avg, + now, + ) + .await; + + (LabelValue::new("ok"), LabelValue::new(&UdpRequestKind::Connect.to_string())) } UdpRequestKind::Announce => { - stats_repository + let new_avg = stats_repository .recalculate_udp_avg_announce_processing_time_ns(req_processing_time) .await; + + // Extendable metrics + stats_repository + .set_gauge( + &MetricName::new("udp_tracker_server_performance_avg_announce_processing_time_ns"), + &LabelSet::from(context.clone()), + new_avg, + now, + ) + .await; + + (LabelValue::new("ok"), LabelValue::new(&UdpRequestKind::Connect.to_string())) } UdpRequestKind::Scrape => { - stats_repository + let new_avg = stats_repository .recalculate_udp_avg_scrape_processing_time_ns(req_processing_time) .await; + + // Extendable metrics + stats_repository + .set_gauge( + &MetricName::new("udp_tracker_server_performance_avg_scrape_processing_time_ns"), + &LabelSet::from(context.clone()), + new_avg, + now, + ) + .await; + + (LabelValue::new("ok"), LabelValue::new(&UdpRequestKind::Connect.to_string())) } }, - UdpResponseKind::Error { opt_req_kind: _ } => {} - } + UdpResponseKind::Error { opt_req_kind: _ } => (LabelValue::new("ok"), LabelValue::ignore()), + }; + + // Extendable metrics + + let mut label_set = LabelSet::from(context); + + label_set.upsert(LabelName::new("result"), result_label_value); + label_set.upsert(LabelName::new("kind"), kind_label_value); + + stats_repository + .increase_counter(&MetricName::new("udp_tracker_server_responses_sent_total"), &label_set, now) + .await; } - Event::UdpError { context } => match context.client_socket_addr().ip() { - std::net::IpAddr::V4(_) => { - stats_repository.increase_udp4_errors().await; - } - std::net::IpAddr::V6(_) => { - stats_repository.increase_udp6_errors().await; + Event::UdpError { context } => { + // Global fixed metrics + match context.client_socket_addr().ip() { + std::net::IpAddr::V4(_) => { + stats_repository.increase_udp4_errors().await; + } + std::net::IpAddr::V6(_) => { + stats_repository.increase_udp6_errors().await; + } } - }, + + // Extendable metrics + stats_repository + .increase_counter( + &MetricName::new("udp_tracker_server_errors_total"), + &LabelSet::from(context), + now, + ) + .await; + } } tracing::debug!("stats: {:?}", stats_repository.get_stats().await); @@ -100,11 +213,13 @@ pub async fn handle_event(event: Event, stats_repository: &Repository) { mod tests { use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + use torrust_tracker_clock::clock::Time; use torrust_tracker_primitives::service_binding::{Protocol, ServiceBinding}; use crate::event::{ConnectionContext, Event, UdpRequestKind}; use crate::statistics::event::handler::handle_event; use crate::statistics::repository::Repository; + use crate::CurrentClock; #[tokio::test] async fn should_increase_the_number_of_aborted_requests_when_it_receives_a_udp_request_aborted_event() { @@ -122,6 +237,7 @@ mod tests { ), }, &stats_repository, + CurrentClock::now(), ) .await; @@ -146,6 +262,7 @@ mod tests { ), }, &stats_repository, + CurrentClock::now(), ) .await; @@ -170,6 +287,7 @@ mod tests { ), }, &stats_repository, + CurrentClock::now(), ) .await; @@ -194,6 +312,7 @@ mod tests { ), }, &stats_repository, + CurrentClock::now(), ) .await; let stats = stats_repository.get_stats().await; @@ -215,6 +334,7 @@ mod tests { ), }, &stats_repository, + CurrentClock::now(), ) .await; let stats = stats_repository.get_stats().await; @@ -238,6 +358,7 @@ mod tests { kind: crate::event::UdpRequestKind::Connect, }, &stats_repository, + CurrentClock::now(), ) .await; @@ -263,6 +384,7 @@ mod tests { kind: crate::event::UdpRequestKind::Announce, }, &stats_repository, + CurrentClock::now(), ) .await; @@ -288,6 +410,7 @@ mod tests { kind: crate::event::UdpRequestKind::Scrape, }, &stats_repository, + CurrentClock::now(), ) .await; @@ -316,6 +439,7 @@ mod tests { req_processing_time: std::time::Duration::from_secs(1), }, &stats_repository, + CurrentClock::now(), ) .await; @@ -340,6 +464,7 @@ mod tests { ), }, &stats_repository, + CurrentClock::now(), ) .await; @@ -365,6 +490,7 @@ mod tests { kind: crate::event::UdpRequestKind::Connect, }, &stats_repository, + CurrentClock::now(), ) .await; @@ -390,6 +516,7 @@ mod tests { kind: crate::event::UdpRequestKind::Announce, }, &stats_repository, + CurrentClock::now(), ) .await; @@ -415,6 +542,7 @@ mod tests { kind: crate::event::UdpRequestKind::Scrape, }, &stats_repository, + CurrentClock::now(), ) .await; @@ -443,6 +571,7 @@ mod tests { req_processing_time: std::time::Duration::from_secs(1), }, &stats_repository, + CurrentClock::now(), ) .await; @@ -466,6 +595,7 @@ mod tests { ), }, &stats_repository, + CurrentClock::now(), ) .await; diff --git a/packages/udp-tracker-server/src/statistics/event/listener.rs b/packages/udp-tracker-server/src/statistics/event/listener.rs index b23260747..c50ce70c9 100644 --- a/packages/udp-tracker-server/src/statistics/event/listener.rs +++ b/packages/udp-tracker-server/src/statistics/event/listener.rs @@ -1,13 +1,15 @@ use tokio::sync::broadcast; +use torrust_tracker_clock::clock::Time; use super::handler::handle_event; use crate::event::Event; use crate::statistics::repository::Repository; +use crate::CurrentClock; pub async fn dispatch_events(mut receiver: broadcast::Receiver, stats_repository: Repository) { loop { match receiver.recv().await { - Ok(event) => handle_event(event, &stats_repository).await, + Ok(event) => handle_event(event, &stats_repository, CurrentClock::now()).await, Err(e) => { tracing::error!("Error receiving udp tracker server event: {:?}", e); break; diff --git a/packages/udp-tracker-server/src/statistics/metrics.rs b/packages/udp-tracker-server/src/statistics/metrics.rs index cce618d74..4fe07e7da 100644 --- a/packages/udp-tracker-server/src/statistics/metrics.rs +++ b/packages/udp-tracker-server/src/statistics/metrics.rs @@ -1,5 +1,11 @@ +use serde::Serialize; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_metrics::metric_collection::MetricCollection; +use torrust_tracker_primitives::DurationSinceUnixEpoch; + /// Metrics collected by the UDP tracker server. -#[derive(Debug, PartialEq, Default)] +#[derive(Debug, PartialEq, Default, Serialize)] pub struct Metrics { // UDP /// Total number of UDP (UDP tracker) requests aborted. @@ -57,4 +63,17 @@ pub struct Metrics { /// Total number of UDP (UDP tracker) `error` requests from IPv6 peers. pub udp6_errors_handled: u64, + + /// A collection of metrics. + pub metric_collection: MetricCollection, +} + +impl Metrics { + pub fn increase_counter(&mut self, metric_name: &MetricName, labels: &LabelSet, now: DurationSinceUnixEpoch) { + self.metric_collection.increase_counter(metric_name, labels, now); + } + + pub fn set_gauge(&mut self, metric_name: &MetricName, labels: &LabelSet, value: f64, now: DurationSinceUnixEpoch) { + self.metric_collection.set_gauge(metric_name, labels, value, now); + } } diff --git a/packages/udp-tracker-server/src/statistics/mod.rs b/packages/udp-tracker-server/src/statistics/mod.rs index 939a41061..535031483 100644 --- a/packages/udp-tracker-server/src/statistics/mod.rs +++ b/packages/udp-tracker-server/src/statistics/mod.rs @@ -4,3 +4,75 @@ pub mod metrics; pub mod repository; pub mod services; pub mod setup; + +use metrics::Metrics; +use torrust_tracker_metrics::metric::description::MetricDescription; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_metrics::unit::Unit; + +#[must_use] +pub fn describe_metrics() -> Metrics { + let mut metrics = Metrics::default(); + + metrics.metric_collection.describe_counter( + &MetricName::new("udp_tracker_server_requests_aborted_total"), + Some(Unit::Count), + Some(MetricDescription::new("Total number of UDP requests aborted")), + ); + + metrics.metric_collection.describe_counter( + &MetricName::new("udp_tracker_server_requests_banned_total"), + Some(Unit::Count), + Some(MetricDescription::new("Total number of UDP requests banned")), + ); + + metrics.metric_collection.describe_counter( + &MetricName::new("udp_tracker_server_requests_received_total"), + Some(Unit::Count), + Some(MetricDescription::new("Total number of UDP requests received")), + ); + + metrics.metric_collection.describe_counter( + &MetricName::new("udp_tracker_server_requests_accepted_total"), + Some(Unit::Count), + Some(MetricDescription::new("Total number of UDP requests accepted")), + ); + + metrics.metric_collection.describe_counter( + &MetricName::new("udp_tracker_server_responses_sent_total"), + Some(Unit::Count), + Some(MetricDescription::new("Total number of UDP responses sent")), + ); + + metrics.metric_collection.describe_counter( + &MetricName::new("udp_tracker_server_errors_total"), + Some(Unit::Count), + Some(MetricDescription::new("Total number of errors processing UDP requests")), + ); + + metrics.metric_collection.describe_gauge( + &MetricName::new("udp_tracker_server_performance_avg_connect_processing_time_ns"), + Some(Unit::Nanoseconds), + Some(MetricDescription::new( + "Average time to process a UDP connect request in nanoseconds", + )), + ); + + metrics.metric_collection.describe_gauge( + &MetricName::new("udp_tracker_server_performance_avg_announce_processing_time_ns"), + Some(Unit::Nanoseconds), + Some(MetricDescription::new( + "Average time to process a UDP announce request in nanoseconds", + )), + ); + + metrics.metric_collection.describe_gauge( + &MetricName::new("udp_tracker_server_performance_avg_scrape_processing_time_ns"), + Some(Unit::Nanoseconds), + Some(MetricDescription::new( + "Average time to process a UDP scrape request in nanoseconds", + )), + ); + + metrics +} diff --git a/packages/udp-tracker-server/src/statistics/repository.rs b/packages/udp-tracker-server/src/statistics/repository.rs index 22e793036..c33c1231c 100644 --- a/packages/udp-tracker-server/src/statistics/repository.rs +++ b/packages/udp-tracker-server/src/statistics/repository.rs @@ -2,7 +2,11 @@ use std::sync::Arc; use std::time::Duration; use tokio::sync::{RwLock, RwLockReadGuard}; +use torrust_tracker_metrics::label::LabelSet; +use torrust_tracker_metrics::metric::MetricName; +use torrust_tracker_primitives::DurationSinceUnixEpoch; +use super::describe_metrics; use super::metrics::Metrics; /// A repository for the tracker metrics. @@ -21,7 +25,7 @@ impl Repository { #[must_use] pub fn new() -> Self { Self { - stats: Arc::new(RwLock::new(Metrics::default())), + stats: Arc::new(RwLock::new(describe_metrics())), } } @@ -80,7 +84,7 @@ impl Repository { #[allow(clippy::cast_precision_loss)] #[allow(clippy::cast_possible_truncation)] #[allow(clippy::cast_sign_loss)] - pub async fn recalculate_udp_avg_connect_processing_time_ns(&self, req_processing_time: Duration) { + pub async fn recalculate_udp_avg_connect_processing_time_ns(&self, req_processing_time: Duration) -> f64 { let mut stats_lock = self.stats.write().await; let req_processing_time = req_processing_time.as_nanos() as f64; @@ -94,12 +98,14 @@ impl Repository { stats_lock.udp_avg_connect_processing_time_ns = new_avg.ceil() as u64; drop(stats_lock); + + new_avg } #[allow(clippy::cast_precision_loss)] #[allow(clippy::cast_possible_truncation)] #[allow(clippy::cast_sign_loss)] - pub async fn recalculate_udp_avg_announce_processing_time_ns(&self, req_processing_time: Duration) { + pub async fn recalculate_udp_avg_announce_processing_time_ns(&self, req_processing_time: Duration) -> f64 { let mut stats_lock = self.stats.write().await; let req_processing_time = req_processing_time.as_nanos() as f64; @@ -114,12 +120,14 @@ impl Repository { stats_lock.udp_avg_announce_processing_time_ns = new_avg.ceil() as u64; drop(stats_lock); + + new_avg } #[allow(clippy::cast_precision_loss)] #[allow(clippy::cast_possible_truncation)] #[allow(clippy::cast_sign_loss)] - pub async fn recalculate_udp_avg_scrape_processing_time_ns(&self, req_processing_time: Duration) { + pub async fn recalculate_udp_avg_scrape_processing_time_ns(&self, req_processing_time: Duration) -> f64 { let mut stats_lock = self.stats.write().await; let req_processing_time = req_processing_time.as_nanos() as f64; @@ -133,6 +141,8 @@ impl Repository { stats_lock.udp_avg_scrape_processing_time_ns = new_avg.ceil() as u64; drop(stats_lock); + + new_avg } pub async fn increase_udp6_requests(&self) { @@ -170,4 +180,16 @@ impl Repository { stats_lock.udp6_errors_handled += 1; drop(stats_lock); } + + pub async fn increase_counter(&self, metric_name: &MetricName, labels: &LabelSet, now: DurationSinceUnixEpoch) { + let mut stats_lock = self.stats.write().await; + stats_lock.increase_counter(metric_name, labels, now); + drop(stats_lock); + } + + pub async fn set_gauge(&self, metric_name: &MetricName, labels: &LabelSet, value: f64, now: DurationSinceUnixEpoch) { + let mut stats_lock = self.stats.write().await; + stats_lock.set_gauge(metric_name, labels, value, now); + drop(stats_lock); + } } diff --git a/packages/udp-tracker-server/src/statistics/services.rs b/packages/udp-tracker-server/src/statistics/services.rs index a16685077..b84bf4cd0 100644 --- a/packages/udp-tracker-server/src/statistics/services.rs +++ b/packages/udp-tracker-server/src/statistics/services.rs @@ -94,6 +94,8 @@ pub async fn get_metrics( udp6_scrapes_handled: stats.udp6_scrapes_handled, udp6_responses: stats.udp6_responses, udp6_errors_handled: stats.udp6_errors_handled, + // Extendable metrics + metric_collection: stats.metric_collection.clone(), }, } } @@ -111,8 +113,8 @@ mod tests { use torrust_tracker_primitives::swarm_metadata::AggregateSwarmMetadata; use torrust_tracker_test_helpers::configuration; - use crate::statistics; use crate::statistics::services::{get_metrics, TrackerMetrics}; + use crate::statistics::{self, describe_metrics}; pub fn tracker_configuration() -> Configuration { configuration::ephemeral() @@ -140,7 +142,7 @@ mod tests { tracker_metrics, TrackerMetrics { torrents_metrics: AggregateSwarmMetadata::default(), - protocol_metrics: statistics::metrics::Metrics::default(), + protocol_metrics: describe_metrics(), } ); }