Skip to main content

siphon_tui/
metrics.rs

1//! Thread-safe metrics collection for real-time TUI dashboard
2
3use parking_lot::RwLock;
4use siphon_protocol::TunnelType;
5use std::collections::VecDeque;
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8
9/// Maximum samples to keep in time-series data (60 seconds at 1 sample/sec)
10const HISTORY_SIZE: usize = 60;
11
12/// Maximum recent requests to display in live log
13const MAX_RECENT_REQUESTS: usize = 100;
14
15/// Thread-safe metrics collector that can be updated from async tasks
16#[derive(Clone)]
17pub struct MetricsCollector {
18    inner: Arc<RwLock<MetricsState>>,
19}
20
21/// Internal metrics state
22pub struct MetricsState {
23    // Tunnel info
24    pub tunnel_info: Option<TunnelInfo>,
25    pub connected_at: Option<Instant>,
26
27    // Request metrics
28    pub total_requests: u64,
29    pub requests_in_progress: u64,
30    pub status_codes: StatusCodeDistribution,
31
32    // Response time tracking
33    response_times: VecDeque<Duration>,
34
35    // Connection metrics
36    pub active_tcp_connections: u64,
37    pub total_tcp_connections: u64,
38    pub bytes_in: u64,
39    pub bytes_out: u64,
40
41    // Error tracking
42    pub error_count: u64,
43    pub last_error: Option<String>,
44
45    // Recent requests for live log
46    pub recent_requests: VecDeque<RequestLogEntry>,
47
48    // Time-series data for graphs (rolling windows)
49    pub request_rate_history: VecDeque<u64>,
50    pub response_time_p50_history: VecDeque<u64>,
51    pub response_time_p99_history: VecDeque<u64>,
52    pub bytes_in_rate_history: VecDeque<u64>,
53    pub bytes_out_rate_history: VecDeque<u64>,
54
55    // Counters for rate calculation (reset each second)
56    requests_this_second: u64,
57    bytes_in_this_second: u64,
58    bytes_out_this_second: u64,
59    last_tick: Instant,
60}
61
62/// Information about the established tunnel
63#[derive(Debug, Clone)]
64pub struct TunnelInfo {
65    pub subdomain: String,
66    pub url: String,
67    pub port: Option<u16>,
68    pub tunnel_type: TunnelType,
69}
70
71/// Distribution of HTTP status codes
72#[derive(Debug, Clone, Default)]
73pub struct StatusCodeDistribution {
74    pub code_2xx: u64,
75    pub code_3xx: u64,
76    pub code_4xx: u64,
77    pub code_5xx: u64,
78}
79
80/// Statistics about response times
81#[derive(Debug, Clone, Default)]
82pub struct ResponseTimeStats {
83    pub min: Option<Duration>,
84    pub max: Option<Duration>,
85    pub avg: Option<Duration>,
86    pub p50: Option<Duration>,
87    pub p95: Option<Duration>,
88    pub p99: Option<Duration>,
89}
90
91/// Entry in the live request log
92#[derive(Debug, Clone)]
93pub struct RequestLogEntry {
94    pub timestamp: chrono::DateTime<chrono::Local>,
95    pub method: String,
96    pub uri: String,
97    pub status: u16,
98    pub duration: Duration,
99    pub bytes: usize,
100}
101
102/// Immutable snapshot of metrics for rendering
103#[derive(Debug, Clone)]
104pub struct MetricsSnapshot {
105    pub tunnel_info: Option<TunnelInfo>,
106    pub uptime: Option<Duration>,
107    pub total_requests: u64,
108    pub requests_per_second: f64,
109    pub status_distribution: StatusCodeDistribution,
110    pub response_times: ResponseTimeStats,
111    pub active_connections: u64,
112    pub total_connections: u64,
113    pub bytes_in: u64,
114    pub bytes_out: u64,
115    pub error_count: u64,
116    pub last_error: Option<String>,
117    pub recent_requests: Vec<RequestLogEntry>,
118
119    // Graph data
120    pub request_rate_history: Vec<u64>,
121    pub response_time_p50_history: Vec<u64>,
122    pub response_time_p99_history: Vec<u64>,
123    pub bytes_in_rate_history: Vec<u64>,
124    pub bytes_out_rate_history: Vec<u64>,
125}
126
127impl Default for MetricsState {
128    fn default() -> Self {
129        Self {
130            tunnel_info: None,
131            connected_at: None,
132            total_requests: 0,
133            requests_in_progress: 0,
134            status_codes: StatusCodeDistribution::default(),
135            response_times: VecDeque::with_capacity(1000),
136            active_tcp_connections: 0,
137            total_tcp_connections: 0,
138            bytes_in: 0,
139            bytes_out: 0,
140            error_count: 0,
141            last_error: None,
142            recent_requests: VecDeque::with_capacity(MAX_RECENT_REQUESTS),
143            request_rate_history: VecDeque::with_capacity(HISTORY_SIZE),
144            response_time_p50_history: VecDeque::with_capacity(HISTORY_SIZE),
145            response_time_p99_history: VecDeque::with_capacity(HISTORY_SIZE),
146            bytes_in_rate_history: VecDeque::with_capacity(HISTORY_SIZE),
147            bytes_out_rate_history: VecDeque::with_capacity(HISTORY_SIZE),
148            requests_this_second: 0,
149            bytes_in_this_second: 0,
150            bytes_out_this_second: 0,
151            last_tick: Instant::now(),
152        }
153    }
154}
155
156impl MetricsCollector {
157    /// Create a new metrics collector
158    pub fn new() -> Self {
159        Self {
160            inner: Arc::new(RwLock::new(MetricsState::default())),
161        }
162    }
163
164    /// Set tunnel information when connection is established
165    pub fn set_tunnel_info(&self, info: TunnelInfo) {
166        let mut state = self.inner.write();
167        state.tunnel_info = Some(info);
168        state.connected_at = Some(Instant::now());
169    }
170
171    /// Record the start of an HTTP request
172    pub fn record_request_start(&self) {
173        let mut state = self.inner.write();
174        state.requests_in_progress += 1;
175    }
176
177    /// Record the completion of an HTTP request
178    pub fn record_request_complete(
179        &self,
180        status: u16,
181        duration: Duration,
182        bytes: usize,
183        method: String,
184        uri: String,
185    ) {
186        let mut state = self.inner.write();
187
188        // Update counters
189        state.total_requests += 1;
190        state.requests_in_progress = state.requests_in_progress.saturating_sub(1);
191        state.requests_this_second += 1;
192
193        // Update status distribution
194        match status {
195            200..=299 => state.status_codes.code_2xx += 1,
196            300..=399 => state.status_codes.code_3xx += 1,
197            400..=499 => state.status_codes.code_4xx += 1,
198            _ => state.status_codes.code_5xx += 1,
199        }
200
201        // Store response time
202        state.response_times.push_back(duration);
203        if state.response_times.len() > 1000 {
204            state.response_times.pop_front();
205        }
206
207        // Add to recent requests
208        state.recent_requests.push_back(RequestLogEntry {
209            timestamp: chrono::Local::now(),
210            method,
211            uri,
212            status,
213            duration,
214            bytes,
215        });
216        if state.recent_requests.len() > MAX_RECENT_REQUESTS {
217            state.recent_requests.pop_front();
218        }
219    }
220
221    /// Record a TCP connection being established
222    pub fn record_tcp_connect(&self) {
223        let mut state = self.inner.write();
224        state.active_tcp_connections += 1;
225        state.total_tcp_connections += 1;
226    }
227
228    /// Record a TCP connection being closed
229    pub fn record_tcp_disconnect(&self) {
230        let mut state = self.inner.write();
231        state.active_tcp_connections = state.active_tcp_connections.saturating_sub(1);
232    }
233
234    /// Record bytes received (inbound)
235    pub fn record_bytes_in(&self, bytes: u64) {
236        let mut state = self.inner.write();
237        state.bytes_in += bytes;
238        state.bytes_in_this_second += bytes;
239    }
240
241    /// Record bytes sent (outbound)
242    pub fn record_bytes_out(&self, bytes: u64) {
243        let mut state = self.inner.write();
244        state.bytes_out += bytes;
245        state.bytes_out_this_second += bytes;
246    }
247
248    /// Record an error
249    pub fn record_error(&self, error: String) {
250        let mut state = self.inner.write();
251        state.error_count += 1;
252        state.last_error = Some(error);
253    }
254
255    /// Tick the metrics collector (call once per second to update history)
256    pub fn tick(&self) {
257        let mut state = self.inner.write();
258
259        // Calculate time since last tick
260        let elapsed = state.last_tick.elapsed();
261        if elapsed < Duration::from_millis(900) {
262            return; // Too soon, skip
263        }
264
265        // Update request rate history
266        let requests_this_sec = state.requests_this_second;
267        state.request_rate_history.push_back(requests_this_sec);
268        if state.request_rate_history.len() > HISTORY_SIZE {
269            state.request_rate_history.pop_front();
270        }
271
272        // Update bytes rate history
273        let bytes_in_this_sec = state.bytes_in_this_second;
274        state.bytes_in_rate_history.push_back(bytes_in_this_sec);
275        if state.bytes_in_rate_history.len() > HISTORY_SIZE {
276            state.bytes_in_rate_history.pop_front();
277        }
278
279        let bytes_out_this_sec = state.bytes_out_this_second;
280        state.bytes_out_rate_history.push_back(bytes_out_this_sec);
281        if state.bytes_out_rate_history.len() > HISTORY_SIZE {
282            state.bytes_out_rate_history.pop_front();
283        }
284
285        // Calculate and store response time percentiles
286        let (p50, p99) = calculate_percentiles(&state.response_times);
287        state
288            .response_time_p50_history
289            .push_back(p50.map(|d| d.as_millis() as u64).unwrap_or(0));
290        if state.response_time_p50_history.len() > HISTORY_SIZE {
291            state.response_time_p50_history.pop_front();
292        }
293
294        state
295            .response_time_p99_history
296            .push_back(p99.map(|d| d.as_millis() as u64).unwrap_or(0));
297        if state.response_time_p99_history.len() > HISTORY_SIZE {
298            state.response_time_p99_history.pop_front();
299        }
300
301        // Reset per-second counters
302        state.requests_this_second = 0;
303        state.bytes_in_this_second = 0;
304        state.bytes_out_this_second = 0;
305        state.last_tick = Instant::now();
306    }
307
308    /// Get an immutable snapshot of current metrics for rendering
309    pub fn snapshot(&self) -> MetricsSnapshot {
310        let state = self.inner.read();
311
312        let uptime = state.connected_at.map(|t| t.elapsed());
313
314        // Calculate requests per second (average over last 10 seconds)
315        let recent_requests: u64 = state.request_rate_history.iter().rev().take(10).sum();
316        let sample_count = state.request_rate_history.len().min(10) as f64;
317        let requests_per_second = if sample_count > 0.0 {
318            recent_requests as f64 / sample_count
319        } else {
320            0.0
321        };
322
323        // Calculate response time stats
324        let response_times = calculate_response_time_stats(&state.response_times);
325
326        MetricsSnapshot {
327            tunnel_info: state.tunnel_info.clone(),
328            uptime,
329            total_requests: state.total_requests,
330            requests_per_second,
331            status_distribution: state.status_codes.clone(),
332            response_times,
333            active_connections: state.active_tcp_connections,
334            total_connections: state.total_tcp_connections,
335            bytes_in: state.bytes_in,
336            bytes_out: state.bytes_out,
337            error_count: state.error_count,
338            last_error: state.last_error.clone(),
339            recent_requests: state.recent_requests.iter().cloned().collect(),
340
341            // Graph data - pad to fixed HISTORY_SIZE for consistent chart rendering
342            request_rate_history: pad_history(&state.request_rate_history, HISTORY_SIZE),
343            response_time_p50_history: pad_history(&state.response_time_p50_history, HISTORY_SIZE),
344            response_time_p99_history: pad_history(&state.response_time_p99_history, HISTORY_SIZE),
345            bytes_in_rate_history: pad_history(&state.bytes_in_rate_history, HISTORY_SIZE),
346            bytes_out_rate_history: pad_history(&state.bytes_out_rate_history, HISTORY_SIZE),
347        }
348    }
349}
350
351/// Pad history data to a fixed size with leading zeros for consistent chart rendering
352fn pad_history(data: &VecDeque<u64>, size: usize) -> Vec<u64> {
353    let current_len = data.len();
354    if current_len >= size {
355        // Already at or exceeds target size, just convert
356        data.iter().copied().collect()
357    } else {
358        // Pad with zeros at the beginning, so newest data is on the right
359        let mut result = vec![0u64; size - current_len];
360        result.extend(data.iter().copied());
361        result
362    }
363}
364
365impl Default for MetricsCollector {
366    fn default() -> Self {
367        Self::new()
368    }
369}
370
371/// Calculate response time statistics from samples
372fn calculate_response_time_stats(samples: &VecDeque<Duration>) -> ResponseTimeStats {
373    if samples.is_empty() {
374        return ResponseTimeStats::default();
375    }
376
377    let mut sorted: Vec<Duration> = samples.iter().copied().collect();
378    sorted.sort();
379
380    let min = sorted.first().copied();
381    let max = sorted.last().copied();
382
383    let sum: Duration = sorted.iter().sum();
384    let avg = Some(sum / sorted.len() as u32);
385
386    let p50 = percentile(&sorted, 50);
387    let p95 = percentile(&sorted, 95);
388    let p99 = percentile(&sorted, 99);
389
390    ResponseTimeStats {
391        min,
392        max,
393        avg,
394        p50,
395        p95,
396        p99,
397    }
398}
399
400/// Calculate a specific percentile from sorted samples
401fn percentile(sorted: &[Duration], p: usize) -> Option<Duration> {
402    if sorted.is_empty() {
403        return None;
404    }
405    let idx = (sorted.len() * p / 100).min(sorted.len() - 1);
406    Some(sorted[idx])
407}
408
409/// Calculate P50 and P99 from samples
410fn calculate_percentiles(samples: &VecDeque<Duration>) -> (Option<Duration>, Option<Duration>) {
411    if samples.is_empty() {
412        return (None, None);
413    }
414
415    let mut sorted: Vec<Duration> = samples.iter().copied().collect();
416    sorted.sort();
417
418    (percentile(&sorted, 50), percentile(&sorted, 99))
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn test_metrics_collector() {
427        let metrics = MetricsCollector::new();
428
429        metrics.record_request_start();
430        metrics.record_request_complete(
431            200,
432            Duration::from_millis(50),
433            1024,
434            "GET".into(),
435            "/api/test".into(),
436        );
437
438        let snapshot = metrics.snapshot();
439        assert_eq!(snapshot.total_requests, 1);
440        assert_eq!(snapshot.status_distribution.code_2xx, 1);
441    }
442
443    #[test]
444    fn test_status_code_distribution() {
445        let metrics = MetricsCollector::new();
446
447        for status in [200, 201, 301, 404, 500] {
448            metrics.record_request_complete(
449                status,
450                Duration::from_millis(10),
451                100,
452                "GET".into(),
453                "/".into(),
454            );
455        }
456
457        let snapshot = metrics.snapshot();
458        assert_eq!(snapshot.status_distribution.code_2xx, 2);
459        assert_eq!(snapshot.status_distribution.code_3xx, 1);
460        assert_eq!(snapshot.status_distribution.code_4xx, 1);
461        assert_eq!(snapshot.status_distribution.code_5xx, 1);
462    }
463
464    #[test]
465    fn test_response_time_percentiles() {
466        let metrics = MetricsCollector::new();
467
468        // Add various response times
469        for ms in [10, 20, 30, 40, 50, 60, 70, 80, 90, 100] {
470            metrics.record_request_complete(
471                200,
472                Duration::from_millis(ms),
473                100,
474                "GET".into(),
475                "/".into(),
476            );
477        }
478
479        let snapshot = metrics.snapshot();
480        assert!(snapshot.response_times.p50.is_some());
481        assert!(snapshot.response_times.p95.is_some());
482        assert!(snapshot.response_times.p99.is_some());
483    }
484
485    #[test]
486    fn test_tcp_connection_tracking() {
487        let metrics = MetricsCollector::new();
488
489        metrics.record_tcp_connect();
490        metrics.record_tcp_connect();
491        assert_eq!(metrics.snapshot().active_connections, 2);
492
493        metrics.record_tcp_disconnect();
494        assert_eq!(metrics.snapshot().active_connections, 1);
495    }
496}