diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-12 09:52:36 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-12 09:52:36 +0000 |
| commit | 93227ce02e484c1e727bfd07ceeb72fd95774170 (patch) | |
| tree | d02c99355b083c4a30f2ae75f42eaaa70d213b24 | |
| parent | a63dc8a9e5f9cad50f4ea7c6c5d2ed544bc70656 (diff) | |
fix(metrics): count repositories on disk on each metrics request
Implements ngit_repositories_total metric by counting *.git directories
on disk every time /metrics is requested (~15s interval by Prometheus).
This approach is simpler than increment-on-create because:
- No need to pass metrics through the relay builder chain
- Always accurate and self-correcting
- Negligible performance impact (~100-200 dir entries)
Changes:
- Add count_repositories_on_disk() static method to Metrics
- Update Metrics::render() to count repos before encoding metrics
- Pass git_data_path to Metrics::new() in main.rs
- Consolidate metrics tests to avoid global Prometheus registry conflicts
Fixes repository count metric issue from Phase 8 deployment plan.
| -rw-r--r-- | src/main.rs | 7 | ||||
| -rw-r--r-- | src/metrics/mod.rs | 159 |
2 files changed, 149 insertions, 17 deletions
diff --git a/src/main.rs b/src/main.rs index 44545b5..8b959a6 100644 --- a/src/main.rs +++ b/src/main.rs | |||
| @@ -40,9 +40,12 @@ async fn main() -> Result<()> { | |||
| 40 | // Initialize metrics if enabled | 40 | // Initialize metrics if enabled |
| 41 | let metrics = if config.metrics_enabled { | 41 | let metrics = if config.metrics_enabled { |
| 42 | info!("Metrics enabled on /metrics endpoint"); | 42 | info!("Metrics enabled on /metrics endpoint"); |
| 43 | Some(Arc::new(Metrics::new( | 43 | let m = Arc::new(Metrics::new( |
| 44 | config.metrics_connection_per_ip_abuse_threshold, | 44 | config.metrics_connection_per_ip_abuse_threshold, |
| 45 | ))) | 45 | Some(config.effective_git_data_path()), |
| 46 | )); | ||
| 47 | info!("Repository count will be updated on each metrics request"); | ||
| 48 | Some(m) | ||
| 46 | } else { | 49 | } else { |
| 47 | info!("Metrics disabled"); | 50 | info!("Metrics disabled"); |
| 48 | None | 51 | None |
diff --git a/src/metrics/mod.rs b/src/metrics/mod.rs index 5420dfd..a16b9fe 100644 --- a/src/metrics/mod.rs +++ b/src/metrics/mod.rs | |||
| @@ -81,8 +81,10 @@ struct MetricsInner { | |||
| 81 | pub events_rejected_total: CounterVec, | 81 | pub events_rejected_total: CounterVec, |
| 82 | 82 | ||
| 83 | // === Repository Metrics === | 83 | // === Repository Metrics === |
| 84 | /// Total repositories hosted | 84 | /// Total repositories hosted (counted from disk on each metrics request) |
| 85 | pub repositories_total: Gauge, | 85 | pub repositories_total: Gauge, |
| 86 | /// Git data directory path for counting repositories on disk | ||
| 87 | pub git_data_path: Option<String>, | ||
| 86 | 88 | ||
| 87 | // === System Health Metrics === | 89 | // === System Health Metrics === |
| 88 | /// Server start time for uptime calculation | 90 | /// Server start time for uptime calculation |
| @@ -97,8 +99,9 @@ impl Metrics { | |||
| 97 | /// | 99 | /// |
| 98 | /// # Arguments | 100 | /// # Arguments |
| 99 | /// * `abuse_threshold` - Number of connections from a single IP before flagging as abuse | 101 | /// * `abuse_threshold` - Number of connections from a single IP before flagging as abuse |
| 100 | pub fn new(abuse_threshold: u32) -> Self { | 102 | /// * `git_data_path` - Optional path to git data directory for counting repositories |
| 101 | let inner = MetricsInner::new(abuse_threshold); | 103 | pub fn new(abuse_threshold: u32, git_data_path: Option<String>) -> Self { |
| 104 | let inner = MetricsInner::new(abuse_threshold, git_data_path); | ||
| 102 | Self { | 105 | Self { |
| 103 | inner: Arc::new(inner), | 106 | inner: Arc::new(inner), |
| 104 | } | 107 | } |
| @@ -214,9 +217,49 @@ impl Metrics { | |||
| 214 | self.inner.repositories_total.set(count as f64); | 217 | self.inner.repositories_total.set(count as f64); |
| 215 | } | 218 | } |
| 216 | 219 | ||
| 217 | /// Increment the repository count | 220 | /// Count all git repositories on disk. |
| 218 | pub fn inc_repositories_total(&self) { | 221 | /// |
| 219 | self.inner.repositories_total.inc(); | 222 | /// This scans the git data directory for all `*.git` directories. |
| 223 | /// | ||
| 224 | /// # Arguments | ||
| 225 | /// * `git_data_path` - Path to the git data directory (e.g., "./data/git") | ||
| 226 | /// | ||
| 227 | /// # Returns | ||
| 228 | /// The number of repositories found on disk | ||
| 229 | pub fn count_repositories_on_disk(git_data_path: &str) -> u64 { | ||
| 230 | use std::fs; | ||
| 231 | use std::path::Path; | ||
| 232 | |||
| 233 | let git_dir = Path::new(git_data_path); | ||
| 234 | if !git_dir.exists() { | ||
| 235 | return 0; | ||
| 236 | } | ||
| 237 | |||
| 238 | let mut count = 0u64; | ||
| 239 | if let Ok(entries) = fs::read_dir(git_dir) { | ||
| 240 | for npub_entry in entries.flatten() { | ||
| 241 | if let Ok(npub_meta) = npub_entry.metadata() { | ||
| 242 | if npub_meta.is_dir() { | ||
| 243 | // This is a npub directory, scan for *.git repos inside | ||
| 244 | if let Ok(repo_entries) = fs::read_dir(npub_entry.path()) { | ||
| 245 | for repo_entry in repo_entries.flatten() { | ||
| 246 | if let Some(name) = repo_entry.file_name().to_str() { | ||
| 247 | if name.ends_with(".git") { | ||
| 248 | if let Ok(repo_meta) = repo_entry.metadata() { | ||
| 249 | if repo_meta.is_dir() { | ||
| 250 | count += 1; | ||
| 251 | } | ||
| 252 | } | ||
| 253 | } | ||
| 254 | } | ||
| 255 | } | ||
| 256 | } | ||
| 257 | } | ||
| 258 | } | ||
| 259 | } | ||
| 260 | } | ||
| 261 | |||
| 262 | count | ||
| 220 | } | 263 | } |
| 221 | 264 | ||
| 222 | // === Rendering === | 265 | // === Rendering === |
| @@ -225,13 +268,20 @@ impl Metrics { | |||
| 225 | /// | 268 | /// |
| 226 | /// This method: | 269 | /// This method: |
| 227 | /// 1. Refreshes the top-N bandwidth metrics if needed | 270 | /// 1. Refreshes the top-N bandwidth metrics if needed |
| 228 | /// 2. Updates uptime | 271 | /// 2. Counts repositories on disk (if git_data_path configured) |
| 229 | /// 3. Gathers all metrics from the registry | 272 | /// 3. Updates uptime |
| 230 | /// 4. Encodes them in Prometheus text format | 273 | /// 4. Gathers all metrics from the registry |
| 274 | /// 5. Encodes them in Prometheus text format | ||
| 231 | pub fn render(&self) -> String { | 275 | pub fn render(&self) -> String { |
| 232 | // Refresh top-N bandwidth repos if needed | 276 | // Refresh top-N bandwidth repos if needed |
| 233 | self.inner.bandwidth_tracker.maybe_refresh_top_n(); | 277 | self.inner.bandwidth_tracker.maybe_refresh_top_n(); |
| 234 | 278 | ||
| 279 | // Count repositories on disk and update metric | ||
| 280 | if let Some(git_data_path) = &self.inner.git_data_path { | ||
| 281 | let count = Self::count_repositories_on_disk(git_data_path); | ||
| 282 | self.inner.repositories_total.set(count as f64); | ||
| 283 | } | ||
| 284 | |||
| 235 | // Gather and encode metrics | 285 | // Gather and encode metrics |
| 236 | let encoder = TextEncoder::new(); | 286 | let encoder = TextEncoder::new(); |
| 237 | let metric_families = REGISTRY.gather(); | 287 | let metric_families = REGISTRY.gather(); |
| @@ -256,7 +306,7 @@ impl Metrics { | |||
| 256 | } | 306 | } |
| 257 | 307 | ||
| 258 | impl MetricsInner { | 308 | impl MetricsInner { |
| 259 | fn new(abuse_threshold: u32) -> Self { | 309 | fn new(abuse_threshold: u32, git_data_path: Option<String>) -> Self { |
| 260 | // Create connection tracker | 310 | // Create connection tracker |
| 261 | let connection_tracker = ConnectionTracker::new(abuse_threshold, ®ISTRY); | 311 | let connection_tracker = ConnectionTracker::new(abuse_threshold, ®ISTRY); |
| 262 | 312 | ||
| @@ -444,6 +494,7 @@ impl MetricsInner { | |||
| 444 | events_stored_total, | 494 | events_stored_total, |
| 445 | events_rejected_total, | 495 | events_rejected_total, |
| 446 | repositories_total, | 496 | repositories_total, |
| 497 | git_data_path, | ||
| 447 | start_time: Instant::now(), | 498 | start_time: Instant::now(), |
| 448 | build_info, | 499 | build_info, |
| 449 | } | 500 | } |
| @@ -505,12 +556,67 @@ mod tests { | |||
| 505 | use super::*; | 556 | use super::*; |
| 506 | 557 | ||
| 507 | #[test] | 558 | #[test] |
| 508 | fn test_metrics_creation() { | 559 | fn test_count_repositories_on_disk() { |
| 509 | // Note: This test may fail if run with other tests due to global registry | 560 | use std::fs; |
| 510 | // In production, consider using a test-specific registry | 561 | use tempfile::TempDir; |
| 511 | let metrics = Metrics::new(10); | 562 | |
| 563 | // Create temporary directory structure | ||
| 564 | let temp_dir = TempDir::new().unwrap(); | ||
| 565 | let git_data_path = temp_dir.path(); | ||
| 566 | |||
| 567 | // Initially should be 0 | ||
| 568 | let count = Metrics::count_repositories_on_disk(git_data_path.to_str().unwrap()); | ||
| 569 | assert_eq!(count, 0); | ||
| 570 | |||
| 571 | // Create some fake repositories | ||
| 572 | let npub1 = git_data_path.join("npub1test"); | ||
| 573 | fs::create_dir_all(&npub1).unwrap(); | ||
| 574 | fs::create_dir_all(npub1.join("repo1.git")).unwrap(); | ||
| 575 | fs::create_dir_all(npub1.join("repo2.git")).unwrap(); | ||
| 576 | |||
| 577 | let npub2 = git_data_path.join("npub2test"); | ||
| 578 | fs::create_dir_all(&npub2).unwrap(); | ||
| 579 | fs::create_dir_all(npub2.join("repo3.git")).unwrap(); | ||
| 580 | |||
| 581 | // Should count 3 repositories | ||
| 582 | let count = Metrics::count_repositories_on_disk(git_data_path.to_str().unwrap()); | ||
| 583 | assert_eq!(count, 3); | ||
| 584 | |||
| 585 | // Create a non-.git directory (should be ignored) | ||
| 586 | fs::create_dir_all(npub1.join("not-a-repo")).unwrap(); | ||
| 587 | let count = Metrics::count_repositories_on_disk(git_data_path.to_str().unwrap()); | ||
| 588 | assert_eq!(count, 3); | ||
| 589 | |||
| 590 | // Create a file with .git suffix (should be ignored, not a directory) | ||
| 591 | fs::write(npub1.join("file.git"), "content").unwrap(); | ||
| 592 | let count = Metrics::count_repositories_on_disk(git_data_path.to_str().unwrap()); | ||
| 593 | assert_eq!(count, 3); | ||
| 594 | } | ||
| 595 | |||
| 596 | /// Comprehensive test for Metrics functionality including repository counting. | ||
| 597 | /// | ||
| 598 | /// NOTE: This test creates a Metrics instance which registers with the global | ||
| 599 | /// Prometheus REGISTRY. Due to this global state, we cannot have multiple tests | ||
| 600 | /// that create Metrics instances - they would conflict. Therefore, this single | ||
| 601 | /// test covers: | ||
| 602 | /// 1. Metrics creation and basic operations | ||
| 603 | /// 2. Repository counting on disk via render() | ||
| 604 | /// | ||
| 605 | /// If additional Metrics tests are needed, they should either be added to this | ||
| 606 | /// test or use a separate test-specific Prometheus registry. | ||
| 607 | #[test] | ||
| 608 | fn test_metrics_with_repository_counting() { | ||
| 609 | use std::fs; | ||
| 610 | use tempfile::TempDir; | ||
| 611 | |||
| 612 | // Create temporary directory structure for repository counting | ||
| 613 | let temp_dir = TempDir::new().unwrap(); | ||
| 614 | let git_data_path = temp_dir.path(); | ||
| 615 | |||
| 616 | // Create Metrics with git_data_path for repository counting | ||
| 617 | let metrics = Metrics::new(10, Some(git_data_path.to_str().unwrap().to_string())); | ||
| 512 | 618 | ||
| 513 | // Test that we can record metrics without panicking | 619 | // Test basic metrics operations |
| 514 | metrics.record_websocket_connection(); | 620 | metrics.record_websocket_connection(); |
| 515 | metrics.record_message_received("REQ"); | 621 | metrics.record_message_received("REQ"); |
| 516 | metrics.record_message_sent("EVENT"); | 622 | metrics.record_message_sent("EVENT"); |
| @@ -520,5 +626,28 @@ mod tests { | |||
| 520 | metrics.record_event_stored(1); | 626 | metrics.record_event_stored(1); |
| 521 | metrics.record_event_rejected(1, "invalid_signature"); | 627 | metrics.record_event_rejected(1, "invalid_signature"); |
| 522 | metrics.set_repositories_total(5); | 628 | metrics.set_repositories_total(5); |
| 629 | |||
| 630 | // Test repository counting via render() | ||
| 631 | // Render should count 0 repos initially (even though we set it to 5 above, | ||
| 632 | // render() recounts from disk) | ||
| 633 | let output = metrics.render(); | ||
| 634 | assert!(output.contains("ngit_repositories_total 0")); | ||
| 635 | |||
| 636 | // Create some repositories | ||
| 637 | let npub1 = git_data_path.join("npub1test"); | ||
| 638 | fs::create_dir_all(&npub1).unwrap(); | ||
| 639 | fs::create_dir_all(npub1.join("repo1.git")).unwrap(); | ||
| 640 | fs::create_dir_all(npub1.join("repo2.git")).unwrap(); | ||
| 641 | |||
| 642 | // Render should count 2 repos now | ||
| 643 | let output = metrics.render(); | ||
| 644 | assert!(output.contains("ngit_repositories_total 2")); | ||
| 645 | |||
| 646 | // Add another repo | ||
| 647 | fs::create_dir_all(npub1.join("repo3.git")).unwrap(); | ||
| 648 | |||
| 649 | // Render should count 3 repos | ||
| 650 | let output = metrics.render(); | ||
| 651 | assert!(output.contains("ngit_repositories_total 3")); | ||
| 523 | } | 652 | } |
| 524 | } | 653 | } |