Skip to main content

raps_kernel/
profiler.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Performance and Resource Profiling
5//!
6//! Provides global tracking for:
7//! - Total execution time
8//! - Kernel load time
9//! - Plugin discovery/load time
10//! - HTTP network overhead (request count, retry count, total duration)
11//! - Memory consumption at report time (snapshot, not peak)
12
13use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
14use std::sync::{Mutex, OnceLock};
15use std::time::{Duration, Instant};
16
17/// Global Profiler state.
18///
19/// Uses `AtomicU64` for hot-path counters (HTTP duration, request/retry counts)
20/// to avoid mutex contention during concurrent multipart uploads.
21/// `Mutex<Option<Duration>>` is retained for one-shot milestone markers
22/// (kernel/plugins load) which are written at most once.
23///
24/// Note: `const fn` constructors for `Mutex`, `OnceLock`, and atomics
25/// require Rust 1.63+ / 1.70+ respectively. This crate requires Rust 1.88+.
26pub struct Profiler {
27    pub start_time: OnceLock<Instant>,
28    pub kernel_load_duration: Mutex<Option<Duration>>,
29    pub plugins_load_duration: Mutex<Option<Duration>>,
30    /// Accumulated HTTP network duration in nanoseconds (lock-free).
31    pub total_http_nanos: AtomicU64,
32    /// Number of logical HTTP requests (one per send_with_retry call).
33    pub http_requests_count: AtomicUsize,
34    /// Number of HTTP retry attempts (retries only, not initial attempts).
35    pub http_retries_count: AtomicUsize,
36    pub enabled: AtomicBool,
37}
38
39impl Profiler {
40    const fn new() -> Self {
41        Self {
42            start_time: OnceLock::new(),
43            kernel_load_duration: Mutex::new(None),
44            plugins_load_duration: Mutex::new(None),
45            total_http_nanos: AtomicU64::new(0),
46            http_requests_count: AtomicUsize::new(0),
47            http_retries_count: AtomicUsize::new(0),
48            enabled: AtomicBool::new(false),
49        }
50    }
51}
52
53static GLOBAL_PROFILER: Profiler = Profiler::new();
54
55/// Initialize the global profiler. Must be called as early as possible.
56pub fn init() {
57    let _ = GLOBAL_PROFILER.start_time.set(Instant::now());
58}
59
60/// Enable profiling output.
61pub fn enable() {
62    GLOBAL_PROFILER.enabled.store(true, Ordering::Relaxed);
63}
64
65/// Check if profiling is enabled.
66pub fn is_enabled() -> bool {
67    GLOBAL_PROFILER.enabled.load(Ordering::Relaxed)
68}
69
70/// Record the time taken to load the kernel/CLI base (called at most once).
71pub fn mark_kernel_loaded() {
72    if !is_enabled() {
73        return;
74    }
75    if let Some(start) = GLOBAL_PROFILER.start_time.get()
76        && let Ok(mut kernel_dur) = GLOBAL_PROFILER.kernel_load_duration.lock()
77        && kernel_dur.is_none()
78    {
79        *kernel_dur = Some(start.elapsed());
80    }
81}
82
83/// Record the time it took to discover and load plugins (called at most once).
84pub fn mark_plugins_loaded(duration: Duration) {
85    if !is_enabled() {
86        return;
87    }
88    if let Ok(mut plugins_dur) = GLOBAL_PROFILER.plugins_load_duration.lock()
89        && plugins_dur.is_none()
90    {
91        *plugins_dur = Some(duration);
92    }
93}
94
95/// Record a completed HTTP request with its total network duration.
96/// Called once per logical request (after all retries are exhausted).
97pub fn record_http_request(duration: Duration) {
98    if !is_enabled() {
99        return;
100    }
101    GLOBAL_PROFILER
102        .total_http_nanos
103        .fetch_add(duration.as_nanos() as u64, Ordering::Relaxed);
104    GLOBAL_PROFILER
105        .http_requests_count
106        .fetch_add(1, Ordering::Relaxed);
107}
108
109/// Record that a retry attempt occurred (called per retry, not per request).
110pub fn record_http_retry() {
111    if !is_enabled() {
112        return;
113    }
114    GLOBAL_PROFILER
115        .http_retries_count
116        .fetch_add(1, Ordering::Relaxed);
117}
118
119/// Print the profiling report if profiling is enabled.
120/// Accepts an optional command name and exit code for structured log context.
121pub fn report(command: Option<&str>, exit_code: Option<i32>) {
122    if !is_enabled() {
123        return;
124    }
125
126    let start_time = match GLOBAL_PROFILER.start_time.get() {
127        Some(t) => t,
128        None => return, // Profiler was never initialized
129    };
130
131    let total_time = start_time.elapsed();
132    let kernel_time = GLOBAL_PROFILER
133        .kernel_load_duration
134        .lock()
135        .unwrap_or_else(|e| e.into_inner())
136        .unwrap_or_default();
137    let plugins_time_opt = *GLOBAL_PROFILER
138        .plugins_load_duration
139        .lock()
140        .unwrap_or_else(|e| e.into_inner());
141    let network_nanos = GLOBAL_PROFILER.total_http_nanos.load(Ordering::Relaxed);
142    let network_time = Duration::from_nanos(network_nanos);
143    let req_count = GLOBAL_PROFILER.http_requests_count.load(Ordering::Relaxed);
144    let retry_count = GLOBAL_PROFILER.http_retries_count.load(Ordering::Relaxed);
145
146    let other_time = total_time.saturating_sub(network_time);
147
148    // Memory snapshot at report time (not peak — OS does not expose peak easily)
149    let memory_str = if let Some(usage) = memory_stats::memory_stats() {
150        format!("{:.2} MB", usage.physical_mem as f64 / 1024.0 / 1024.0)
151    } else {
152        "Unknown".to_string()
153    };
154
155    let cmd = command.unwrap_or("unknown");
156    let code = exit_code.unwrap_or(0);
157
158    // Structured log for file log / aggregation
159    tracing::info!(
160        command = cmd,
161        exit_code = code,
162        total_ms = total_time.as_millis() as u64,
163        kernel_ms = kernel_time.as_millis() as u64,
164        plugins_ms = plugins_time_opt.map(|d| d.as_millis() as u64),
165        network_ms = network_time.as_millis() as u64,
166        other_ms = other_time.as_millis() as u64,
167        http_requests = req_count,
168        http_retries = retry_count,
169        memory = %memory_str,
170        "Performance profile"
171    );
172
173    // Human-readable output to stderr
174    let plugins_str = match plugins_time_opt {
175        Some(d) => format!("{:.3}s", d.as_secs_f64()),
176        None => "N/A".to_string(),
177    };
178
179    eprintln!("\n=== RAPS Performance Profile ===");
180    eprintln!(
181        "{:<22} {:.3}s",
182        "Total Execution Time:",
183        total_time.as_secs_f64()
184    );
185    eprintln!(
186        "{:<22} {:.3}s",
187        "Kernel Load Time:",
188        kernel_time.as_secs_f64()
189    );
190    eprintln!("{:<22} {}", "Plugins Load Time:", plugins_str);
191    eprintln!(
192        "{:<22} {:.3}s",
193        "Total Network Time:",
194        network_time.as_secs_f64()
195    );
196    eprintln!("{:<22} {}", "HTTP Requests:", req_count);
197    if retry_count > 0 {
198        eprintln!("{:<22} {}", "HTTP Retries:", retry_count);
199    }
200    let health_snap = crate::api_health::snapshot();
201    if health_snap.sample_count > 0 {
202        eprintln!(
203            "{:<22} {}",
204            "Avg Latency:",
205            crate::api_health::format_duration_ms(health_snap.avg_latency)
206        );
207        eprintln!(
208            "{:<22} {}",
209            "Latency Jitter:",
210            crate::api_health::format_duration_ms(health_snap.jitter)
211        );
212        eprintln!("{:<22} {}", "API Health:", health_snap.health_status);
213    }
214    eprintln!("{:<22} {:.3}s", "Other Time:", other_time.as_secs_f64());
215    eprintln!("{:<22} {}", "Memory (at exit):", memory_str);
216    eprintln!("================================");
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    // Note: Tests use a fresh Profiler instance to avoid global state interference.
224    // The global GLOBAL_PROFILER is shared across all tests, so we test the logic
225    // via the atomic/mutex primitives directly.
226
227    #[test]
228    fn test_atomic_http_duration_accumulates() {
229        let counter = AtomicU64::new(0);
230        let d1 = Duration::from_millis(100);
231        let d2 = Duration::from_millis(250);
232        counter.fetch_add(d1.as_nanos() as u64, Ordering::Relaxed);
233        counter.fetch_add(d2.as_nanos() as u64, Ordering::Relaxed);
234        let total = Duration::from_nanos(counter.load(Ordering::Relaxed));
235        assert_eq!(total, Duration::from_millis(350));
236    }
237
238    #[test]
239    fn test_atomic_request_count() {
240        let counter = AtomicUsize::new(0);
241        counter.fetch_add(1, Ordering::Relaxed);
242        counter.fetch_add(1, Ordering::Relaxed);
243        counter.fetch_add(1, Ordering::Relaxed);
244        assert_eq!(counter.load(Ordering::Relaxed), 3);
245    }
246
247    #[test]
248    fn test_mark_plugins_loaded_only_once() {
249        let plugins_dur: Mutex<Option<Duration>> = Mutex::new(None);
250
251        // First call sets the value
252        {
253            let mut dur = plugins_dur.lock().unwrap();
254            if dur.is_none() {
255                *dur = Some(Duration::from_millis(50));
256            }
257        }
258
259        // Second call should NOT overwrite
260        {
261            let mut dur = plugins_dur.lock().unwrap();
262            if dur.is_none() {
263                *dur = Some(Duration::from_millis(200));
264            }
265        }
266
267        let result = plugins_dur.lock().unwrap().unwrap();
268        assert_eq!(result, Duration::from_millis(50));
269    }
270
271    #[test]
272    fn test_mark_kernel_loaded_only_once() {
273        let kernel_dur: Mutex<Option<Duration>> = Mutex::new(None);
274
275        {
276            let mut dur = kernel_dur.lock().unwrap();
277            if dur.is_none() {
278                *dur = Some(Duration::from_millis(30));
279            }
280        }
281
282        {
283            let mut dur = kernel_dur.lock().unwrap();
284            if dur.is_none() {
285                *dur = Some(Duration::from_millis(100));
286            }
287        }
288
289        let result = kernel_dur.lock().unwrap().unwrap();
290        assert_eq!(result, Duration::from_millis(30));
291    }
292
293    #[test]
294    fn test_plugins_none_displays_as_na() {
295        let plugins_time_opt: Option<Duration> = None;
296        let plugins_str = match plugins_time_opt {
297            Some(d) => format!("{:.3}s", d.as_secs_f64()),
298            None => "N/A".to_string(),
299        };
300        assert_eq!(plugins_str, "N/A");
301    }
302
303    #[test]
304    fn test_plugins_some_displays_as_seconds() {
305        let plugins_time_opt: Option<Duration> = Some(Duration::from_millis(123));
306        let plugins_str = match plugins_time_opt {
307            Some(d) => format!("{:.3}s", d.as_secs_f64()),
308            None => "N/A".to_string(),
309        };
310        assert_eq!(plugins_str, "0.123s");
311    }
312
313    #[test]
314    fn test_other_time_saturating_sub() {
315        let total = Duration::from_millis(500);
316        let network = Duration::from_millis(300);
317        let other = total.saturating_sub(network);
318        assert_eq!(other, Duration::from_millis(200));
319    }
320
321    #[test]
322    fn test_other_time_saturating_sub_overflow() {
323        // Network time exceeding total (shouldn't happen, but shouldn't panic)
324        let total = Duration::from_millis(100);
325        let network = Duration::from_millis(300);
326        let other = total.saturating_sub(network);
327        assert_eq!(other, Duration::ZERO);
328    }
329
330    #[test]
331    fn test_enabled_flag() {
332        let enabled = AtomicBool::new(false);
333        assert!(!enabled.load(Ordering::Relaxed));
334        enabled.store(true, Ordering::Relaxed);
335        assert!(enabled.load(Ordering::Relaxed));
336    }
337}