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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/core/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod core;
mod network;
mod node;
pub mod observability;
68 changes: 68 additions & 0 deletions src/observability/labels.rs
Original file line number Diff line number Diff line change
@@ -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<LookupTableLevel> for LevelBucket {
fn from(level: LookupTableLevel) -> Self {
LevelBucket::from_level(level)
}
}
32 changes: 32 additions & 0 deletions src/observability/labels_test.rs
Original file line number Diff line number Diff line change
@@ -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}"
);
}
}
33 changes: 33 additions & 0 deletions src/observability/metrics.rs
Original file line number Diff line number Diff line change
@@ -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<T> NodeMetrics for T where T: SearchMetrics + LookupTableMetrics + NetworkMetrics {}
30 changes: 30 additions & 0 deletions src/observability/mod.rs
Original file line number Diff line number Diff line change
@@ -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,
};
Loading