Skip to main content

swiftide_core/
statistics.rs

1//! Pipeline statistics collection
2//!
3//! This module provides comprehensive monitoring and observability for pipelines,
4//! including node counts, token usage, and timing information.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use swiftide::indexing::Pipeline;
10//!
11//! let pipeline = Pipeline::from_loader(loader)
12//!     .then(transformer)
13//!     .store(storage);
14//!
15//! // Run pipeline
16//! pipeline.run().await?;
17//!
18//! // Get statistics
19//! let stats = pipeline.stats();
20//! println!("Processed {} nodes in {:?}", stats.nodes_processed, stats.duration());
21//! ```
22
23use std::{
24    collections::HashMap,
25    sync::{
26        Mutex, MutexGuard,
27        atomic::{AtomicU64, Ordering},
28    },
29    time::{Duration, Instant},
30};
31
32const TWO_POW_32_F64: f64 = 4_294_967_296.0;
33
34fn lock_recover<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
35    mutex
36        .lock()
37        .unwrap_or_else(std::sync::PoisonError::into_inner)
38}
39
40fn u64_to_f64(value: u64) -> f64 {
41    let upper = u32::try_from(value >> 32).expect("upper 32 bits always fit in u32");
42    let lower =
43        u32::try_from(value & u64::from(u32::MAX)).expect("lower 32 bits always fit in u32");
44
45    f64::from(upper) * TWO_POW_32_F64 + f64::from(lower)
46}
47
48/// Statistics for a single model's usage
49#[derive(Debug, Clone, Default, PartialEq)]
50pub struct ModelUsage {
51    /// Number of prompt tokens used
52    pub prompt_tokens: u64,
53    /// Number of completion tokens used
54    pub completion_tokens: u64,
55    /// Total tokens used (prompt + completion)
56    pub total_tokens: u64,
57    /// Number of requests made to this model
58    pub request_count: u64,
59}
60
61impl ModelUsage {
62    /// Creates a new `ModelUsage` with zero counts
63    #[must_use]
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// Records token usage for a single request
69    pub fn record(&mut self, prompt_tokens: u64, completion_tokens: u64) {
70        self.prompt_tokens += prompt_tokens;
71        self.completion_tokens += completion_tokens;
72        self.total_tokens += prompt_tokens + completion_tokens;
73        self.request_count += 1;
74    }
75}
76
77/// A snapshot of pipeline statistics at a specific point in time
78///
79/// This struct contains immutable statistics collected during pipeline execution.
80#[derive(Debug, Clone, Default, PartialEq)]
81pub struct PipelineStats {
82    /// Total number of nodes processed
83    pub nodes_processed: u64,
84    /// Total number of nodes that resulted in error
85    pub nodes_failed: u64,
86    /// Total number of nodes persisted to storage
87    pub nodes_stored: u64,
88    /// Total number of transformations applied
89    pub transformations_applied: u64,
90    /// Token usage per model
91    pub token_usage: HashMap<String, ModelUsage>,
92    /// When the pipeline started
93    started_at: Option<Instant>,
94    /// When the pipeline completed
95    completed_at: Option<Instant>,
96}
97
98impl PipelineStats {
99    /// Creates a new empty `PipelineStats`
100    #[must_use]
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// Returns the duration of the pipeline execution
106    ///
107    /// If the pipeline has not started, returns `None`.
108    /// If the pipeline has started but not completed, returns the elapsed time since start.
109    #[must_use]
110    pub fn duration(&self) -> Option<Duration> {
111        match (self.started_at, self.completed_at) {
112            (Some(start), Some(end)) => Some(end.duration_since(start)),
113            (Some(start), None) => Some(start.elapsed()),
114            _ => None,
115        }
116    }
117
118    /// Calculates nodes processed per second
119    ///
120    /// Returns `None` if the pipeline hasn't started or if no nodes have been processed.
121    #[must_use]
122    pub fn nodes_per_second(&self) -> Option<f64> {
123        let duration = self.duration()?;
124        if duration.as_secs_f64() == 0.0 || self.nodes_processed == 0 {
125            return None;
126        }
127        Some(u64_to_f64(self.nodes_processed) / duration.as_secs_f64())
128    }
129
130    /// Returns the total number of tokens used across all models
131    #[must_use]
132    pub fn total_tokens(&self) -> u64 {
133        self.token_usage.values().map(|u| u.total_tokens).sum()
134    }
135
136    /// Returns the total number of LLM requests made
137    #[must_use]
138    pub fn total_requests(&self) -> u64 {
139        self.token_usage.values().map(|u| u.request_count).sum()
140    }
141
142    /// Returns the total prompt tokens across all models
143    #[must_use]
144    pub fn total_prompt_tokens(&self) -> u64 {
145        self.token_usage.values().map(|u| u.prompt_tokens).sum()
146    }
147
148    /// Returns the total completion tokens across all models
149    #[must_use]
150    pub fn total_completion_tokens(&self) -> u64 {
151        self.token_usage.values().map(|u| u.completion_tokens).sum()
152    }
153}
154
155/// Thread-safe statistics collector for pipeline execution
156///
157/// This collector uses atomic counters for lock-free updates and can be safely
158/// shared across multiple threads during pipeline processing.
159#[derive(Debug)]
160pub struct StatsCollector {
161    nodes_processed: AtomicU64,
162    nodes_failed: AtomicU64,
163    nodes_stored: AtomicU64,
164    transformations_applied: AtomicU64,
165    token_usage: Mutex<HashMap<String, ModelUsage>>,
166    started_at: Mutex<Option<Instant>>,
167    completed_at: Mutex<Option<Instant>>,
168}
169
170impl Default for StatsCollector {
171    fn default() -> Self {
172        Self::new()
173    }
174}
175
176impl StatsCollector {
177    /// Creates a new `StatsCollector`
178    #[must_use]
179    pub fn new() -> Self {
180        Self {
181            nodes_processed: AtomicU64::new(0),
182            nodes_failed: AtomicU64::new(0),
183            nodes_stored: AtomicU64::new(0),
184            transformations_applied: AtomicU64::new(0),
185            token_usage: Mutex::new(HashMap::new()),
186            started_at: Mutex::new(None),
187            completed_at: Mutex::new(None),
188        }
189    }
190
191    /// Marks the pipeline as started
192    pub fn start(&self) {
193        let mut started = lock_recover(&self.started_at);
194        *started = Some(Instant::now());
195    }
196
197    /// Marks the pipeline as completed
198    pub fn complete(&self) {
199        let mut completed = lock_recover(&self.completed_at);
200        *completed = Some(Instant::now());
201    }
202
203    /// Increments the count of processed nodes
204    pub fn increment_nodes_processed(&self, count: u64) {
205        self.nodes_processed.fetch_add(count, Ordering::Relaxed);
206    }
207
208    /// Increments the count of failed nodes
209    pub fn increment_nodes_failed(&self, count: u64) {
210        self.nodes_failed.fetch_add(count, Ordering::Relaxed);
211    }
212
213    /// Increments the count of stored nodes
214    pub fn increment_nodes_stored(&self, count: u64) {
215        self.nodes_stored.fetch_add(count, Ordering::Relaxed);
216    }
217
218    /// Increments the count of applied transformations
219    pub fn increment_transformations(&self, count: u64) {
220        self.transformations_applied
221            .fetch_add(count, Ordering::Relaxed);
222    }
223
224    /// Records token usage for a specific model
225    ///
226    /// This method is compatible with OpenTelemetry LLM specification.
227    ///
228    /// # Arguments
229    ///
230    /// * `model` - The name/identifier of the model
231    /// * `prompt_tokens` - Number of tokens in the prompt
232    /// * `completion_tokens` - Number of tokens in the completion
233    pub fn record_token_usage(
234        &self,
235        model: impl AsRef<str>,
236        prompt_tokens: u64,
237        completion_tokens: u64,
238    ) {
239        let mut usage = lock_recover(&self.token_usage);
240        let model_usage = usage.entry(model.as_ref().to_string()).or_default();
241        model_usage.record(prompt_tokens, completion_tokens);
242    }
243
244    /// Returns a snapshot of the current statistics
245    #[must_use]
246    pub fn get_stats(&self) -> PipelineStats {
247        PipelineStats {
248            nodes_processed: self.nodes_processed.load(Ordering::Relaxed),
249            nodes_failed: self.nodes_failed.load(Ordering::Relaxed),
250            nodes_stored: self.nodes_stored.load(Ordering::Relaxed),
251            transformations_applied: self.transformations_applied.load(Ordering::Relaxed),
252            token_usage: lock_recover(&self.token_usage).clone(),
253            started_at: *lock_recover(&self.started_at),
254            completed_at: *lock_recover(&self.completed_at),
255        }
256    }
257}
258
259impl Clone for StatsCollector {
260    fn clone(&self) -> Self {
261        Self {
262            nodes_processed: AtomicU64::new(self.nodes_processed.load(Ordering::Relaxed)),
263            nodes_failed: AtomicU64::new(self.nodes_failed.load(Ordering::Relaxed)),
264            nodes_stored: AtomicU64::new(self.nodes_stored.load(Ordering::Relaxed)),
265            transformations_applied: AtomicU64::new(
266                self.transformations_applied.load(Ordering::Relaxed),
267            ),
268            token_usage: Mutex::new(lock_recover(&self.token_usage).clone()),
269            started_at: Mutex::new(*lock_recover(&self.started_at)),
270            completed_at: Mutex::new(*lock_recover(&self.completed_at)),
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn test_stats_collector() {
281        let collector = StatsCollector::new();
282
283        collector.start();
284
285        collector.increment_nodes_processed(10);
286        collector.increment_nodes_failed(2);
287        collector.increment_nodes_stored(8);
288        collector.increment_transformations(15);
289
290        collector.complete();
291
292        let stats = collector.get_stats();
293
294        assert_eq!(stats.nodes_processed, 10);
295        assert_eq!(stats.nodes_failed, 2);
296        assert_eq!(stats.nodes_stored, 8);
297        assert_eq!(stats.transformations_applied, 15);
298        assert!(stats.duration().is_some());
299        assert!(stats.nodes_per_second().is_some());
300    }
301
302    #[test]
303    fn test_model_usage() {
304        let mut usage = ModelUsage::new();
305
306        usage.record(100, 50);
307        usage.record(200, 100);
308
309        assert_eq!(usage.prompt_tokens, 300);
310        assert_eq!(usage.completion_tokens, 150);
311        assert_eq!(usage.total_tokens, 450);
312        assert_eq!(usage.request_count, 2);
313    }
314
315    #[test]
316    fn test_record_token_usage() {
317        let collector = StatsCollector::new();
318
319        collector.record_token_usage("gpt-4", 100, 50);
320        collector.record_token_usage("gpt-4", 200, 100);
321        collector.record_token_usage("gpt-3.5", 50, 25);
322
323        let stats = collector.get_stats();
324
325        assert_eq!(stats.token_usage.len(), 2);
326
327        let gpt4_usage = stats.token_usage.get("gpt-4").unwrap();
328        assert_eq!(gpt4_usage.prompt_tokens, 300);
329        assert_eq!(gpt4_usage.completion_tokens, 150);
330        assert_eq!(gpt4_usage.request_count, 2);
331
332        assert_eq!(stats.total_tokens(), 525);
333        assert_eq!(stats.total_requests(), 3);
334    }
335
336    #[test]
337    fn test_empty_stats() {
338        let stats = PipelineStats::new();
339
340        assert_eq!(stats.nodes_processed, 0);
341        assert_eq!(stats.nodes_failed, 0);
342        assert_eq!(stats.total_tokens(), 0);
343        assert!(stats.duration().is_none());
344        assert!(stats.nodes_per_second().is_none());
345    }
346
347    #[test]
348    fn test_stats_collector_clone() {
349        let collector = StatsCollector::new();
350        collector.increment_nodes_processed(5);
351        collector.record_token_usage("model-1", 10, 5);
352
353        let cloned = collector.clone();
354
355        // Modify original
356        collector.increment_nodes_processed(3);
357
358        // Cloned should have original value
359        let cloned_stats = cloned.get_stats();
360        assert_eq!(cloned_stats.nodes_processed, 5);
361
362        // Original should have updated value
363        let original_stats = collector.get_stats();
364        assert_eq!(original_stats.nodes_processed, 8);
365    }
366
367    #[test]
368    fn test_pipeline_stats_duration_while_running() {
369        let collector = StatsCollector::new();
370        collector.start();
371
372        let stats = collector.get_stats();
373
374        // Should return Some while running
375        assert!(stats.duration().is_some());
376        assert_eq!(stats.completed_at, None);
377    }
378}