upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/metrics/bandwidth.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-12-04 15:17:04 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-12-04 15:24:19 +0000
commitfd0c87c787d0626b3546fa571541c9c809711821 (patch)
tree934f20d973127f380b807d2bd44b25c197cf349c /src/metrics/bandwidth.rs
parent762cd8e815e797f173f541795de774fbbf978fc3 (diff)
add prometheus metrics
Diffstat (limited to 'src/metrics/bandwidth.rs')
-rw-r--r--src/metrics/bandwidth.rs301
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
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::time::{Duration, Instant};
15
16use dashmap::DashMap;
17use prometheus::{GaugeVec, Opts, Registry};
18
19/// Default number of top repositories to expose in metrics
20const DEFAULT_TOP_N: usize = 10;
21
22/// Default refresh interval for top-N calculation (60 seconds)
23const 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.
37pub 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
57impl 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)]
190mod 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(&registry);
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(&registry, 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(&registry);
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(&registry);
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(&registry, 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(&registry);
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