diff --git a/src/core/model/mod.rs b/src/core/model/mod.rs index 2de6281..4fb4dc9 100644 --- a/src/core/model/mod.rs +++ b/src/core/model/mod.rs @@ -2,7 +2,7 @@ pub const IDENTIFIER_SIZE_BYTES: usize = 32; pub mod address; -pub(crate) mod direction; +pub mod direction; pub mod identifier; pub mod identity; pub(crate) mod join; diff --git a/src/lib.rs b/src/lib.rs index af57158..30fbabf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ pub mod core; mod network; mod node; +pub mod observability; diff --git a/src/observability/labels.rs b/src/observability/labels.rs new file mode 100644 index 0000000..977c500 --- /dev/null +++ b/src/observability/labels.rs @@ -0,0 +1,68 @@ +use crate::core::LookupTableLevel; + +/// Outcome of a completed identifier search, used as a metric label. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SearchOutcome { + /// A matching neighbor was returned by the search. + Found, + /// No matching neighbor existed at any level, so the search returned the + /// caller's own identifier (the Aspnes & Shah own-identifier fallback). + NotFound, +} + +/// Type of a network message, used as a metric label. One variant per event kind. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MessageType { + /// A test-only string payload (not used in production). + TestMessage, + /// An identifier-search request payload. + SearchByIdRequest, + /// An identifier-search response payload. + SearchByIdResponse, +} + +/// The lower bound (inclusive) of the [`LevelBucket::Medium`] range. +const MEDIUM_LEVEL_FLOOR: LookupTableLevel = 16; +/// The lower bound (inclusive) of the [`LevelBucket::High`] range. +const HIGH_LEVEL_FLOOR: LookupTableLevel = 64; +/// The lower bound (inclusive) of the [`LevelBucket::Overflow`] range. +const OVERFLOW_LEVEL_FLOOR: LookupTableLevel = 256; + +/// A coarse bucket over a [`LookupTableLevel`], used as a metric label. +/// +/// Levels can number in the hundreds; labeling by raw level would explode +/// cardinality, so it is bucketed. The mapping is total, with +/// [`LevelBucket::Overflow`] catching levels at or beyond the expected maximum. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LevelBucket { + /// Levels `0..16`. + Low, + /// Levels `16..64`. + Medium, + /// Levels `64..256`. + High, + /// Levels `256` and above (not expected in normal operation). + Overflow, +} + +impl LevelBucket { + /// Maps a lookup-table level to its bucket. Total over all `usize` values. + #[must_use] + pub const fn from_level(level: LookupTableLevel) -> Self { + if level < MEDIUM_LEVEL_FLOOR { + LevelBucket::Low + } else if level < HIGH_LEVEL_FLOOR { + LevelBucket::Medium + } else if level < OVERFLOW_LEVEL_FLOOR { + LevelBucket::High + } else { + LevelBucket::Overflow + } + } +} + +impl From for LevelBucket { + fn from(level: LookupTableLevel) -> Self { + LevelBucket::from_level(level) + } +} diff --git a/src/observability/labels_test.rs b/src/observability/labels_test.rs new file mode 100644 index 0000000..b19574e --- /dev/null +++ b/src/observability/labels_test.rs @@ -0,0 +1,32 @@ +use crate::core::LookupTableLevel; +use crate::observability::labels::LevelBucket; + +/// Levels are bucketed into the expected fixed ranges, including the boundary +/// values and the overflow tail. +#[test] +fn test_level_bucket_ranges() { + let cases: &[(LookupTableLevel, LevelBucket)] = &[ + (0, LevelBucket::Low), + (15, LevelBucket::Low), + (16, LevelBucket::Medium), + (63, LevelBucket::Medium), + (64, LevelBucket::High), + (255, LevelBucket::High), + (256, LevelBucket::Overflow), + (usize::MAX, LevelBucket::Overflow), + ]; + + for (level, expected) in cases { + assert_eq!( + LevelBucket::from_level(*level), + *expected, + "level {level} bucketed incorrectly" + ); + // `From` must agree with `from_level`. + assert_eq!( + LevelBucket::from(*level), + *expected, + "from disagreed at {level}" + ); + } +} diff --git a/src/observability/metrics.rs b/src/observability/metrics.rs new file mode 100644 index 0000000..aa15547 --- /dev/null +++ b/src/observability/metrics.rs @@ -0,0 +1,33 @@ +use crate::core::model::direction::Direction; +use crate::observability::labels::{LevelBucket, MessageType, SearchOutcome}; +use std::time::Duration; + +/// Records metrics for identifier-search operations. +#[unimock::unimock(api = SearchMetricsMock)] +pub trait SearchMetrics: Send + Sync { + /// Records a completed search by its outcome, hop count, and duration. + fn record_search(&self, outcome: SearchOutcome, hops: usize, elapsed: Duration); +} + +/// Records metrics for lookup-table mutations. +#[unimock::unimock(api = LookupTableMetricsMock)] +pub trait LookupTableMetrics: Send + Sync { + /// Records installation of a neighbor at the given level bucket and direction. + fn record_neighbor_install(&self, level: LevelBucket, direction: Direction); +} + +/// Records metrics for network message flow. +#[unimock::unimock(api = NetworkMetricsMock)] +pub trait NetworkMetrics: Send + Sync { + /// Records that a message of the given type was sent. + fn record_message_sent(&self, message_type: MessageType); + + /// Records that a message of the given type was received. + fn record_message_received(&self, message_type: MessageType); +} + +/// The full metric surface a node records against, composing the per-subsystem traits. +pub trait NodeMetrics: SearchMetrics + LookupTableMetrics + NetworkMetrics {} + +// Backends implement the three sub-traits; `NodeMetrics` follows automatically and is never implemented directly. +impl NodeMetrics for T where T: SearchMetrics + LookupTableMetrics + NetworkMetrics {} diff --git a/src/observability/mod.rs b/src/observability/mod.rs new file mode 100644 index 0000000..bb1715f --- /dev/null +++ b/src/observability/mod.rs @@ -0,0 +1,30 @@ +//! Observability interfaces for Skip Graph nodes. +//! +//! Defines the interface the node code calls to record metric events. It is +//! backend-free (no OpenTelemetry, Prometheus, or other metrics dependency); +//! concrete backends are implemented elsewhere. +//! +//! # Infallibility contract +//! +//! Every metric method returns `()` and must never panic: emitting a metric +//! cannot fail or influence the operation being measured. Implementations +//! swallow their own backend errors. +//! +//! # Cardinality contract +//! +//! Labels are closed enums only ([`SearchOutcome`], [`MessageType`], +//! [`LevelBucket`], [`Direction`]), never identifiers or other unbounded +//! values, so the type system makes a high-cardinality label unrepresentable. + +mod labels; +mod metrics; + +#[cfg(test)] +mod labels_test; + +pub use crate::core::model::direction::Direction; +pub use labels::{LevelBucket, MessageType, SearchOutcome}; +pub use metrics::{ + LookupTableMetrics, LookupTableMetricsMock, NetworkMetrics, NetworkMetricsMock, NodeMetrics, + SearchMetrics, SearchMetricsMock, +};