diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-04 15:17:04 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-04 15:24:19 +0000 |
| commit | fd0c87c787d0626b3546fa571541c9c809711821 (patch) | |
| tree | 934f20d973127f380b807d2bd44b25c197cf349c /src/metrics/bandwidth.rs | |
| parent | 762cd8e815e797f173f541795de774fbbf978fc3 (diff) | |
add prometheus metrics
Diffstat (limited to 'src/metrics/bandwidth.rs')
| -rw-r--r-- | src/metrics/bandwidth.rs | 301 |
1 files changed, 301 insertions, 0 deletions
diff --git a/src/metrics/bandwidth.rs b/src/metrics/bandwidth.rs new file mode 100644 index 0000000..d2c53e8 --- /dev/null +++ b/src/metrics/bandwidth.rs | |||
| @@ -0,0 +1,301 @@ | |||
| 1 | //! Repository bandwidth tracking with cardinality control. | ||
| 2 | //! | ||
| 3 | //! This module tracks bandwidth per repository but only exposes the top N | ||
| 4 | //! repositories to Prometheus to prevent cardinality explosion with many repos. | ||
| 5 | //! | ||
| 6 | //! # Cardinality Control | ||
| 7 | //! | ||
| 8 | //! - All per-repo bandwidth is tracked internally in a `DashMap<RepoId, u64>` | ||
| 9 | //! - Every 60 seconds, the top 10 are calculated and exposed to Prometheus | ||
| 10 | //! - Previous repo labels are cleared before setting new ones | ||
| 11 | //! - Prometheus only ever sees ~10 label values, keeping cardinality low | ||
| 12 | |||
| 13 | use std::sync::atomic::{AtomicU64, Ordering}; | ||
| 14 | use std::time::{Duration, Instant}; | ||
| 15 | |||
| 16 | use dashmap::DashMap; | ||
| 17 | use prometheus::{GaugeVec, Opts, Registry}; | ||
| 18 | |||
| 19 | /// Default number of top repositories to expose in metrics | ||
| 20 | const DEFAULT_TOP_N: usize = 10; | ||
| 21 | |||
| 22 | /// Default refresh interval for top-N calculation (60 seconds) | ||
| 23 | const DEFAULT_REFRESH_INTERVAL: Duration = Duration::from_secs(60); | ||
| 24 | |||
| 25 | /// Tracks bandwidth per repository with top-N exposure to Prometheus. | ||
| 26 | /// | ||
| 27 | /// # Design | ||
| 28 | /// | ||
| 29 | /// All repositories are tracked internally for accurate total bandwidth, | ||
| 30 | /// but only the top N by bytes transferred are exposed to Prometheus. | ||
| 31 | /// This prevents cardinality explosion when hosting thousands of repositories. | ||
| 32 | /// | ||
| 33 | /// # Thread Safety | ||
| 34 | /// | ||
| 35 | /// Uses `DashMap` for lock-free concurrent access and atomics for | ||
| 36 | /// the refresh timestamp. | ||
| 37 | pub struct BandwidthTracker { | ||
| 38 | /// Internal: tracks ALL repos (memory only, not exposed) | ||
| 39 | all_repos: DashMap<String, u64>, | ||
| 40 | |||
| 41 | /// Exposed to Prometheus: only top N repos | ||
| 42 | top_repos_gauge: GaugeVec, | ||
| 43 | |||
| 44 | /// Last refresh timestamp (stored as nanos since some epoch) | ||
| 45 | last_refresh_nanos: AtomicU64, | ||
| 46 | |||
| 47 | /// Instant when the tracker was created (for relative timing) | ||
| 48 | start_instant: Instant, | ||
| 49 | |||
| 50 | /// Number of top repos to expose | ||
| 51 | top_n: usize, | ||
| 52 | |||
| 53 | /// Refresh interval | ||
| 54 | refresh_interval: Duration, | ||
| 55 | } | ||
| 56 | |||
| 57 | impl BandwidthTracker { | ||
| 58 | /// Creates a new BandwidthTracker and registers metrics with Prometheus. | ||
| 59 | /// | ||
| 60 | /// Uses default settings: | ||
| 61 | /// - Top 10 repositories exposed | ||
| 62 | /// - 60 second refresh interval | ||
| 63 | pub fn new(registry: &Registry) -> Self { | ||
| 64 | Self::with_config(registry, DEFAULT_TOP_N, DEFAULT_REFRESH_INTERVAL) | ||
| 65 | } | ||
| 66 | |||
| 67 | /// Creates a new BandwidthTracker with custom configuration. | ||
| 68 | /// | ||
| 69 | /// # Arguments | ||
| 70 | /// | ||
| 71 | /// * `registry` - Prometheus registry to register metrics with | ||
| 72 | /// * `top_n` - Number of top repositories to expose in metrics | ||
| 73 | /// * `refresh_interval` - How often to recalculate the top-N list | ||
| 74 | pub fn with_config(registry: &Registry, top_n: usize, refresh_interval: Duration) -> Self { | ||
| 75 | let top_repos_gauge = GaugeVec::new( | ||
| 76 | Opts::new( | ||
| 77 | "ngit_git_top_repos_bytes", | ||
| 78 | "Top repositories by bandwidth (refreshed periodically)", | ||
| 79 | ), | ||
| 80 | &["repo"], | ||
| 81 | ) | ||
| 82 | .unwrap(); | ||
| 83 | registry.register(Box::new(top_repos_gauge.clone())).unwrap(); | ||
| 84 | |||
| 85 | Self { | ||
| 86 | all_repos: DashMap::new(), | ||
| 87 | top_repos_gauge, | ||
| 88 | last_refresh_nanos: AtomicU64::new(0), | ||
| 89 | start_instant: Instant::now(), | ||
| 90 | top_n, | ||
| 91 | refresh_interval, | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 95 | /// Records bytes transferred for a repository. | ||
| 96 | /// | ||
| 97 | /// # Arguments | ||
| 98 | /// | ||
| 99 | /// * `repo_id` - Repository identifier (e.g., npub or repo name) | ||
| 100 | /// * `bytes` - Number of bytes transferred | ||
| 101 | pub fn record_transfer(&self, repo_id: &str, bytes: u64) { | ||
| 102 | self.all_repos | ||
| 103 | .entry(repo_id.to_string()) | ||
| 104 | .and_modify(|v| *v = v.saturating_add(bytes)) | ||
| 105 | .or_insert(bytes); | ||
| 106 | } | ||
| 107 | |||
| 108 | /// Conditionally refreshes the top-N list if the refresh interval has elapsed. | ||
| 109 | /// | ||
| 110 | /// This method is designed to be called frequently (e.g., on every | ||
| 111 | /// `/metrics` request) without performance impact - it only does work | ||
| 112 | /// when the refresh interval has elapsed. | ||
| 113 | pub fn maybe_refresh_top_n(&self) { | ||
| 114 | let elapsed_nanos = self.start_instant.elapsed().as_nanos() as u64; | ||
| 115 | let last_refresh = self.last_refresh_nanos.load(Ordering::Relaxed); | ||
| 116 | let interval_nanos = self.refresh_interval.as_nanos() as u64; | ||
| 117 | |||
| 118 | // Check if enough time has passed since last refresh | ||
| 119 | if elapsed_nanos.saturating_sub(last_refresh) >= interval_nanos { | ||
| 120 | // Try to update the timestamp atomically to prevent concurrent refreshes | ||
| 121 | if self | ||
| 122 | .last_refresh_nanos | ||
| 123 | .compare_exchange(last_refresh, elapsed_nanos, Ordering::SeqCst, Ordering::Relaxed) | ||
| 124 | .is_ok() | ||
| 125 | { | ||
| 126 | self.refresh_top_n(); | ||
| 127 | } | ||
| 128 | } | ||
| 129 | } | ||
| 130 | |||
| 131 | /// Forces a refresh of the top-N list. | ||
| 132 | /// | ||
| 133 | /// This recalculates which repositories are in the top N by bandwidth | ||
| 134 | /// and updates the Prometheus gauges accordingly. | ||
| 135 | pub fn refresh_top_n(&self) { | ||
| 136 | // Collect all repo data | ||
| 137 | let mut sorted: Vec<_> = self | ||
| 138 | .all_repos | ||
| 139 | .iter() | ||
| 140 | .map(|r| (r.key().clone(), *r.value())) | ||
| 141 | .collect(); | ||
| 142 | |||
| 143 | // Sort by bytes descending | ||
| 144 | sorted.sort_by(|a, b| b.1.cmp(&a.1)); | ||
| 145 | |||
| 146 | // Clear old labels and set new top N | ||
| 147 | self.top_repos_gauge.reset(); | ||
| 148 | for (repo, bytes) in sorted.into_iter().take(self.top_n) { | ||
| 149 | self.top_repos_gauge | ||
| 150 | .with_label_values(&[&repo]) | ||
| 151 | .set(bytes as f64); | ||
| 152 | } | ||
| 153 | } | ||
| 154 | |||
| 155 | /// Returns the total bytes transferred for a specific repository. | ||
| 156 | /// | ||
| 157 | /// Returns `None` if the repository has not been seen. | ||
| 158 | pub fn get_repo_bytes(&self, repo_id: &str) -> Option<u64> { | ||
| 159 | self.all_repos.get(repo_id).map(|v| *v) | ||
| 160 | } | ||
| 161 | |||
| 162 | /// Returns the total bytes transferred across all repositories. | ||
| 163 | pub fn total_bytes(&self) -> u64 { | ||
| 164 | self.all_repos.iter().map(|r| *r.value()).sum() | ||
| 165 | } | ||
| 166 | |||
| 167 | /// Returns the number of repositories being tracked. | ||
| 168 | pub fn repo_count(&self) -> usize { | ||
| 169 | self.all_repos.len() | ||
| 170 | } | ||
| 171 | |||
| 172 | /// Returns the top N repositories by bandwidth. | ||
| 173 | /// | ||
| 174 | /// This is a snapshot and may not match the Prometheus gauges if | ||
| 175 | /// a refresh hasn't occurred recently. | ||
| 176 | pub fn get_top_repos(&self) -> Vec<(String, u64)> { | ||
| 177 | let mut sorted: Vec<_> = self | ||
| 178 | .all_repos | ||
| 179 | .iter() | ||
| 180 | .map(|r| (r.key().clone(), *r.value())) | ||
| 181 | .collect(); | ||
| 182 | |||
| 183 | sorted.sort_by(|a, b| b.1.cmp(&a.1)); | ||
| 184 | sorted.truncate(self.top_n); | ||
| 185 | sorted | ||
| 186 | } | ||
| 187 | } | ||
| 188 | |||
| 189 | #[cfg(test)] | ||
| 190 | mod tests { | ||
| 191 | use super::*; | ||
| 192 | |||
| 193 | fn test_registry() -> Registry { | ||
| 194 | Registry::new() | ||
| 195 | } | ||
| 196 | |||
| 197 | #[test] | ||
| 198 | fn test_bandwidth_tracking() { | ||
| 199 | let registry = test_registry(); | ||
| 200 | let tracker = BandwidthTracker::new(®istry); | ||
| 201 | |||
| 202 | // Record transfers | ||
| 203 | tracker.record_transfer("repo-a", 1000); | ||
| 204 | tracker.record_transfer("repo-b", 2000); | ||
| 205 | tracker.record_transfer("repo-a", 500); // Additional transfer to repo-a | ||
| 206 | |||
| 207 | assert_eq!(tracker.get_repo_bytes("repo-a"), Some(1500)); | ||
| 208 | assert_eq!(tracker.get_repo_bytes("repo-b"), Some(2000)); | ||
| 209 | assert_eq!(tracker.get_repo_bytes("repo-c"), None); | ||
| 210 | assert_eq!(tracker.total_bytes(), 3500); | ||
| 211 | assert_eq!(tracker.repo_count(), 2); | ||
| 212 | } | ||
| 213 | |||
| 214 | #[test] | ||
| 215 | fn test_top_n_repos() { | ||
| 216 | let registry = test_registry(); | ||
| 217 | let tracker = BandwidthTracker::with_config(®istry, 3, Duration::from_secs(60)); | ||
| 218 | |||
| 219 | // Create 5 repos with different bandwidth | ||
| 220 | tracker.record_transfer("repo-1", 100); | ||
| 221 | tracker.record_transfer("repo-2", 500); | ||
| 222 | tracker.record_transfer("repo-3", 200); | ||
| 223 | tracker.record_transfer("repo-4", 800); | ||
| 224 | tracker.record_transfer("repo-5", 300); | ||
| 225 | |||
| 226 | let top = tracker.get_top_repos(); | ||
| 227 | assert_eq!(top.len(), 3); | ||
| 228 | assert_eq!(top[0], ("repo-4".to_string(), 800)); | ||
| 229 | assert_eq!(top[1], ("repo-2".to_string(), 500)); | ||
| 230 | assert_eq!(top[2], ("repo-5".to_string(), 300)); | ||
| 231 | } | ||
| 232 | |||
| 233 | #[test] | ||
| 234 | fn test_refresh_updates_gauge() { | ||
| 235 | let registry = test_registry(); | ||
| 236 | let tracker = BandwidthTracker::new(®istry); | ||
| 237 | |||
| 238 | tracker.record_transfer("high-bandwidth-repo", 10_000_000); | ||
| 239 | tracker.record_transfer("low-bandwidth-repo", 1000); | ||
| 240 | |||
| 241 | // Force a refresh | ||
| 242 | tracker.refresh_top_n(); | ||
| 243 | |||
| 244 | // Verify the gauge values (we can't easily access them directly, | ||
| 245 | // but we can verify the tracker state is correct) | ||
| 246 | assert_eq!(tracker.repo_count(), 2); | ||
| 247 | assert_eq!(tracker.total_bytes(), 10_001_000); | ||
| 248 | } | ||
| 249 | |||
| 250 | #[test] | ||
| 251 | fn test_saturating_add() { | ||
| 252 | let registry = test_registry(); | ||
| 253 | let tracker = BandwidthTracker::new(®istry); | ||
| 254 | |||
| 255 | // Test that we don't overflow | ||
| 256 | tracker.record_transfer("huge-repo", u64::MAX - 100); | ||
| 257 | tracker.record_transfer("huge-repo", 200); | ||
| 258 | |||
| 259 | // Should saturate to MAX, not overflow | ||
| 260 | assert_eq!(tracker.get_repo_bytes("huge-repo"), Some(u64::MAX)); | ||
| 261 | } | ||
| 262 | |||
| 263 | #[test] | ||
| 264 | fn test_maybe_refresh_respects_interval() { | ||
| 265 | let registry = test_registry(); | ||
| 266 | // Use a very short interval for testing | ||
| 267 | let tracker = BandwidthTracker::with_config(®istry, 10, Duration::from_millis(10)); | ||
| 268 | |||
| 269 | tracker.record_transfer("repo-a", 1000); | ||
| 270 | |||
| 271 | // First call should trigger refresh (no previous refresh) | ||
| 272 | tracker.maybe_refresh_top_n(); | ||
| 273 | |||
| 274 | // Add more data | ||
| 275 | tracker.record_transfer("repo-b", 2000); | ||
| 276 | |||
| 277 | // Immediate second call should NOT trigger refresh | ||
| 278 | let count_before = tracker.repo_count(); | ||
| 279 | tracker.maybe_refresh_top_n(); | ||
| 280 | assert_eq!(tracker.repo_count(), count_before); | ||
| 281 | |||
| 282 | // Wait for interval to pass | ||
| 283 | std::thread::sleep(Duration::from_millis(15)); | ||
| 284 | |||
| 285 | // Now it should refresh | ||
| 286 | tracker.maybe_refresh_top_n(); | ||
| 287 | } | ||
| 288 | |||
| 289 | #[test] | ||
| 290 | fn test_empty_tracker() { | ||
| 291 | let registry = test_registry(); | ||
| 292 | let tracker = BandwidthTracker::new(®istry); | ||
| 293 | |||
| 294 | assert_eq!(tracker.total_bytes(), 0); | ||
| 295 | assert_eq!(tracker.repo_count(), 0); | ||
| 296 | assert!(tracker.get_top_repos().is_empty()); | ||
| 297 | |||
| 298 | // Refresh should not panic on empty data | ||
| 299 | tracker.refresh_top_n(); | ||
| 300 | } | ||
| 301 | } \ No newline at end of file | ||