Skip to main content

debtmap/effects/
telemetry.rs

1//! Writer Effect for Analysis Telemetry
2//!
3//! This module provides the Writer Effect pattern for collecting analysis telemetry
4//! without threading state through function parameters. Using this approach:
5//!
6//! - Function signatures stay clean (no telemetry receivers to pass)
7//! - Testing is easier (no need to mock telemetry infrastructure)
8//! - Business logic is decoupled from logging/metrics concerns
9//! - Events accumulate automatically alongside computation
10//!
11//! # Example
12//!
13//! ```rust,ignore
14//! use debtmap::effects::telemetry::{tell_event, AnalysisEvent, AnalysisMetrics};
15//! use stillwater::WriterEffectExt;
16//!
17//! fn analyze_file(path: &Path) -> impl WriterEffect<Output = FileMetrics, Writes = AnalysisMetrics> {
18//!     let start = Instant::now();
19//!     tell_event(AnalysisEvent::FileStarted { path: path.to_path_buf() })
20//!         .and_then(|_| do_analysis(path))
21//!         .tap_tell(|metrics| AnalysisMetrics::event(
22//!             AnalysisEvent::ComplexityCalculated {
23//!                 path: path.to_path_buf(),
24//!                 cognitive: metrics.cognitive,
25//!                 cyclomatic: metrics.cyclomatic,
26//!             }
27//!         ))
28//! }
29//!
30//! // Execute and collect telemetry
31//! let (result, metrics) = analyze_file(path).run_writer(&env).await;
32//! let summary: AnalysisSummary = metrics.into();
33//! ```
34
35use std::collections::HashMap;
36use std::path::PathBuf;
37use std::time::Instant;
38
39use stillwater::effect::writer::{tell, WriterEffect};
40use stillwater::{Monoid, Semigroup};
41
42use crate::core::types::Severity;
43
44/// Analysis phases for phase-level telemetry.
45#[derive(Debug, Clone, PartialEq, Eq, Hash)]
46pub enum AnalysisPhase {
47    /// File discovery phase
48    Discovery,
49    /// Source code parsing phase
50    Parsing,
51    /// Complexity calculation phase
52    Complexity,
53    /// Debt detection phase
54    DebtDetection,
55    /// Risk assessment phase
56    RiskAssessment,
57    /// Report generation phase
58    Reporting,
59}
60
61impl AnalysisPhase {
62    /// Get a display name for the phase.
63    pub fn display_name(&self) -> &'static str {
64        match self {
65            Self::Discovery => "Discovery",
66            Self::Parsing => "Parsing",
67            Self::Complexity => "Complexity Analysis",
68            Self::DebtDetection => "Debt Detection",
69            Self::RiskAssessment => "Risk Assessment",
70            Self::Reporting => "Report Generation",
71        }
72    }
73}
74
75/// Events emitted during analysis for telemetry collection.
76///
77/// These events capture key analysis milestones without coupling
78/// the analysis logic to any specific logging/metrics infrastructure.
79#[derive(Debug, Clone)]
80pub enum AnalysisEvent {
81    /// Emitted when file analysis begins.
82    FileStarted {
83        /// Path to the file being analyzed.
84        path: PathBuf,
85        /// Timestamp when analysis started.
86        timestamp: Instant,
87    },
88
89    /// Emitted when file analysis completes successfully.
90    FileCompleted {
91        /// Path to the analyzed file.
92        path: PathBuf,
93        /// Duration of analysis in milliseconds.
94        duration_ms: u64,
95    },
96
97    /// Emitted when file analysis fails.
98    FileFailed {
99        /// Path to the file that failed.
100        path: PathBuf,
101        /// Error message.
102        error: String,
103    },
104
105    /// Emitted after successful parsing.
106    ParseComplete {
107        /// Path to the parsed file.
108        path: PathBuf,
109        /// Number of functions found.
110        function_count: usize,
111    },
112
113    /// Emitted when complexity is calculated for a file.
114    ComplexityCalculated {
115        /// Path to the analyzed file.
116        path: PathBuf,
117        /// Cognitive complexity score.
118        cognitive: u32,
119        /// Cyclomatic complexity score.
120        cyclomatic: u32,
121    },
122
123    /// Emitted when a debt item is detected.
124    DebtItemDetected {
125        /// Path where debt was found.
126        path: PathBuf,
127        /// Severity of the debt item.
128        severity: Severity,
129        /// Category of the debt (e.g., "complexity", "duplication").
130        category: String,
131    },
132
133    /// Emitted when an analysis phase starts.
134    PhaseStarted {
135        /// The phase that started.
136        phase: AnalysisPhase,
137        /// Timestamp when the phase started.
138        timestamp: Instant,
139    },
140
141    /// Emitted when an analysis phase completes.
142    PhaseCompleted {
143        /// The phase that completed.
144        phase: AnalysisPhase,
145        /// Duration of the phase in milliseconds.
146        duration_ms: u64,
147    },
148}
149
150impl AnalysisEvent {
151    /// Create a file started event with current timestamp.
152    pub fn file_started(path: PathBuf) -> Self {
153        Self::FileStarted {
154            path,
155            timestamp: Instant::now(),
156        }
157    }
158
159    /// Create a file completed event.
160    pub fn file_completed(path: PathBuf, duration_ms: u64) -> Self {
161        Self::FileCompleted { path, duration_ms }
162    }
163
164    /// Create a file failed event.
165    pub fn file_failed(path: PathBuf, error: impl Into<String>) -> Self {
166        Self::FileFailed {
167            path,
168            error: error.into(),
169        }
170    }
171
172    /// Create a parse complete event.
173    pub fn parse_complete(path: PathBuf, function_count: usize) -> Self {
174        Self::ParseComplete {
175            path,
176            function_count,
177        }
178    }
179
180    /// Create a complexity calculated event.
181    pub fn complexity_calculated(path: PathBuf, cognitive: u32, cyclomatic: u32) -> Self {
182        Self::ComplexityCalculated {
183            path,
184            cognitive,
185            cyclomatic,
186        }
187    }
188
189    /// Create a debt item detected event.
190    pub fn debt_detected(path: PathBuf, severity: Severity, category: impl Into<String>) -> Self {
191        Self::DebtItemDetected {
192            path,
193            severity,
194            category: category.into(),
195        }
196    }
197
198    /// Create a phase started event with current timestamp.
199    pub fn phase_started(phase: AnalysisPhase) -> Self {
200        Self::PhaseStarted {
201            phase,
202            timestamp: Instant::now(),
203        }
204    }
205
206    /// Create a phase completed event.
207    pub fn phase_completed(phase: AnalysisPhase, duration_ms: u64) -> Self {
208        Self::PhaseCompleted { phase, duration_ms }
209    }
210}
211
212/// Collection of analysis events with Monoid-based accumulation.
213///
214/// This type implements `Semigroup` and `Monoid` to enable automatic
215/// accumulation via the Writer Effect pattern. Events are stored in
216/// a `Vec` for efficient appending.
217#[derive(Debug, Clone, Default)]
218pub struct AnalysisMetrics {
219    /// Collected analysis events.
220    pub events: Vec<AnalysisEvent>,
221}
222
223impl AnalysisMetrics {
224    /// Create a new empty metrics collection.
225    pub fn new() -> Self {
226        Self::default()
227    }
228
229    /// Create metrics containing a single event.
230    pub fn event(event: AnalysisEvent) -> Self {
231        Self {
232            events: vec![event],
233        }
234    }
235
236    /// Create metrics from multiple events.
237    pub fn events(events: Vec<AnalysisEvent>) -> Self {
238        Self { events }
239    }
240
241    /// Get the number of events collected.
242    pub fn len(&self) -> usize {
243        self.events.len()
244    }
245
246    /// Check if there are no events.
247    pub fn is_empty(&self) -> bool {
248        self.events.is_empty()
249    }
250
251    /// Iterate over events.
252    pub fn iter(&self) -> impl Iterator<Item = &AnalysisEvent> {
253        self.events.iter()
254    }
255
256    /// Filter events by predicate.
257    pub fn filter<F>(&self, predicate: F) -> Self
258    where
259        F: Fn(&AnalysisEvent) -> bool,
260    {
261        Self {
262            events: self
263                .events
264                .iter()
265                .filter(|e| predicate(e))
266                .cloned()
267                .collect(),
268        }
269    }
270
271    /// Get only file started events.
272    pub fn file_started_events(&self) -> impl Iterator<Item = &AnalysisEvent> {
273        self.events
274            .iter()
275            .filter(|e| matches!(e, AnalysisEvent::FileStarted { .. }))
276    }
277
278    /// Get only file completed events.
279    pub fn file_completed_events(&self) -> impl Iterator<Item = &AnalysisEvent> {
280        self.events
281            .iter()
282            .filter(|e| matches!(e, AnalysisEvent::FileCompleted { .. }))
283    }
284
285    /// Get only debt detected events.
286    pub fn debt_detected_events(&self) -> impl Iterator<Item = &AnalysisEvent> {
287        self.events
288            .iter()
289            .filter(|e| matches!(e, AnalysisEvent::DebtItemDetected { .. }))
290    }
291}
292
293/// Semigroup implementation for AnalysisMetrics.
294///
295/// Combines two metrics by concatenating their event vectors.
296impl Semigroup for AnalysisMetrics {
297    fn combine(mut self, other: Self) -> Self {
298        self.events.extend(other.events);
299        self
300    }
301}
302
303/// Monoid implementation for AnalysisMetrics.
304///
305/// The identity element is an empty metrics collection.
306impl Monoid for AnalysisMetrics {
307    fn empty() -> Self {
308        Self::default()
309    }
310}
311
312/// Summary statistics aggregated from analysis events.
313///
314/// This type provides a high-level view of analysis results,
315/// derived from the collected events.
316#[derive(Debug, Clone, Default)]
317pub struct AnalysisSummary {
318    /// Number of files processed.
319    pub files_processed: usize,
320    /// Number of files that failed processing.
321    pub files_failed: usize,
322    /// Total duration across all files (milliseconds).
323    pub total_duration_ms: u64,
324    /// Total number of functions parsed.
325    pub total_functions: usize,
326    /// Average cognitive complexity across files.
327    pub avg_cognitive_complexity: f64,
328    /// Average cyclomatic complexity across files.
329    pub avg_cyclomatic_complexity: f64,
330    /// Debt items grouped by severity.
331    pub debt_items_by_severity: HashMap<Severity, usize>,
332    /// Debt items grouped by category.
333    pub debt_items_by_category: HashMap<String, usize>,
334    /// Phase durations in milliseconds.
335    pub phase_durations: HashMap<AnalysisPhase, u64>,
336}
337
338impl AnalysisSummary {
339    /// Total number of debt items detected.
340    pub fn total_debt_items(&self) -> usize {
341        self.debt_items_by_severity.values().sum()
342    }
343
344    /// Average file processing time in milliseconds.
345    pub fn avg_file_duration_ms(&self) -> f64 {
346        if self.files_processed == 0 {
347            0.0
348        } else {
349            self.total_duration_ms as f64 / self.files_processed as f64
350        }
351    }
352}
353
354impl From<AnalysisMetrics> for AnalysisSummary {
355    fn from(metrics: AnalysisMetrics) -> Self {
356        let mut summary = AnalysisSummary::default();
357        let mut cognitive_sum: u64 = 0;
358        let mut cyclomatic_sum: u64 = 0;
359        let mut complexity_count: usize = 0;
360
361        for event in metrics.events {
362            match event {
363                AnalysisEvent::FileStarted { .. } => {
364                    // Counted via FileCompleted
365                }
366                AnalysisEvent::FileCompleted { duration_ms, .. } => {
367                    summary.files_processed += 1;
368                    summary.total_duration_ms += duration_ms;
369                }
370                AnalysisEvent::FileFailed { .. } => {
371                    summary.files_failed += 1;
372                }
373                AnalysisEvent::ParseComplete { function_count, .. } => {
374                    summary.total_functions += function_count;
375                }
376                AnalysisEvent::ComplexityCalculated {
377                    cognitive,
378                    cyclomatic,
379                    ..
380                } => {
381                    cognitive_sum += cognitive as u64;
382                    cyclomatic_sum += cyclomatic as u64;
383                    complexity_count += 1;
384                }
385                AnalysisEvent::DebtItemDetected {
386                    severity, category, ..
387                } => {
388                    *summary.debt_items_by_severity.entry(severity).or_insert(0) += 1;
389                    *summary.debt_items_by_category.entry(category).or_insert(0) += 1;
390                }
391                AnalysisEvent::PhaseStarted { .. } => {
392                    // Timing captured via PhaseCompleted
393                }
394                AnalysisEvent::PhaseCompleted { phase, duration_ms } => {
395                    summary.phase_durations.insert(phase, duration_ms);
396                }
397            }
398        }
399
400        // Calculate averages
401        if complexity_count > 0 {
402            summary.avg_cognitive_complexity = cognitive_sum as f64 / complexity_count as f64;
403            summary.avg_cyclomatic_complexity = cyclomatic_sum as f64 / complexity_count as f64;
404        }
405
406        summary
407    }
408}
409
410/// Emit a single analysis event to be accumulated.
411///
412/// This is the primary helper for emitting telemetry events
413/// within a Writer Effect context.
414///
415/// # Example
416///
417/// ```rust,ignore
418/// use debtmap::effects::telemetry::{tell_event, AnalysisEvent};
419///
420/// let effect = tell_event(AnalysisEvent::file_started(path.to_path_buf()));
421/// ```
422pub fn tell_event<E, Env>(
423    event: AnalysisEvent,
424) -> impl WriterEffect<Output = (), Error = E, Env = Env, Writes = AnalysisMetrics>
425where
426    E: Send + 'static,
427    Env: Clone + Send + Sync + 'static,
428{
429    tell(AnalysisMetrics::event(event))
430}
431
432/// Emit multiple analysis events to be accumulated.
433///
434/// Use this when you have several events to emit at once.
435///
436/// # Example
437///
438/// ```rust,ignore
439/// use debtmap::effects::telemetry::{tell_events, AnalysisEvent};
440///
441/// let effect = tell_events(vec![
442///     AnalysisEvent::parse_complete(path.clone(), 5),
443///     AnalysisEvent::complexity_calculated(path, 10, 8),
444/// ]);
445/// ```
446pub fn tell_events<E, Env>(
447    events: Vec<AnalysisEvent>,
448) -> impl WriterEffect<Output = (), Error = E, Env = Env, Writes = AnalysisMetrics>
449where
450    E: Send + 'static,
451    Env: Clone + Send + Sync + 'static,
452{
453    tell(AnalysisMetrics::events(events))
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    #[test]
461    fn analysis_metrics_monoid_empty() {
462        let empty = AnalysisMetrics::empty();
463        assert!(empty.is_empty());
464    }
465
466    #[test]
467    fn analysis_metrics_monoid_identity() {
468        let metrics = AnalysisMetrics::event(AnalysisEvent::file_started(PathBuf::from("test.rs")));
469        let empty = AnalysisMetrics::empty();
470
471        // Right identity: a.combine(empty) == a
472        let combined = metrics.clone().combine(empty.clone());
473        assert_eq!(combined.len(), 1);
474
475        // Left identity: empty.combine(a) == a
476        let combined = empty.combine(metrics.clone());
477        assert_eq!(combined.len(), 1);
478    }
479
480    #[test]
481    fn analysis_metrics_semigroup_combine() {
482        let m1 = AnalysisMetrics::event(AnalysisEvent::file_started(PathBuf::from("a.rs")));
483        let m2 = AnalysisMetrics::event(AnalysisEvent::file_started(PathBuf::from("b.rs")));
484        let m3 = AnalysisMetrics::event(AnalysisEvent::file_started(PathBuf::from("c.rs")));
485
486        // Verify combination adds events
487        let combined = m1.clone().combine(m2.clone());
488        assert_eq!(combined.len(), 2);
489
490        // Verify associativity: (a.combine(b)).combine(c) == a.combine(b.combine(c))
491        let left = m1.clone().combine(m2.clone()).combine(m3.clone());
492        let right = m1.combine(m2.combine(m3));
493        assert_eq!(left.len(), right.len());
494        assert_eq!(left.len(), 3);
495    }
496
497    #[test]
498    fn analysis_summary_from_metrics() {
499        let metrics = AnalysisMetrics::events(vec![
500            AnalysisEvent::file_completed(PathBuf::from("a.rs"), 100),
501            AnalysisEvent::file_completed(PathBuf::from("b.rs"), 200),
502            AnalysisEvent::file_failed(PathBuf::from("c.rs"), "parse error"),
503            AnalysisEvent::parse_complete(PathBuf::from("a.rs"), 5),
504            AnalysisEvent::parse_complete(PathBuf::from("b.rs"), 10),
505            AnalysisEvent::complexity_calculated(PathBuf::from("a.rs"), 10, 8),
506            AnalysisEvent::complexity_calculated(PathBuf::from("b.rs"), 20, 16),
507            AnalysisEvent::debt_detected(PathBuf::from("a.rs"), Severity::Warning, "complexity"),
508            AnalysisEvent::debt_detected(PathBuf::from("b.rs"), Severity::Critical, "security"),
509        ]);
510
511        let summary: AnalysisSummary = metrics.into();
512
513        assert_eq!(summary.files_processed, 2);
514        assert_eq!(summary.files_failed, 1);
515        assert_eq!(summary.total_duration_ms, 300);
516        assert_eq!(summary.total_functions, 15);
517        assert!((summary.avg_cognitive_complexity - 15.0).abs() < 0.01); // (10+20)/2
518        assert!((summary.avg_cyclomatic_complexity - 12.0).abs() < 0.01); // (8+16)/2
519        assert_eq!(summary.total_debt_items(), 2);
520        assert_eq!(
521            summary.debt_items_by_severity.get(&Severity::Warning),
522            Some(&1)
523        );
524        assert_eq!(
525            summary.debt_items_by_severity.get(&Severity::Critical),
526            Some(&1)
527        );
528        assert_eq!(summary.debt_items_by_category.get("complexity"), Some(&1));
529        assert_eq!(summary.debt_items_by_category.get("security"), Some(&1));
530    }
531
532    #[test]
533    fn analysis_summary_avg_file_duration() {
534        let summary = AnalysisSummary {
535            files_processed: 4,
536            total_duration_ms: 400,
537            ..Default::default()
538        };
539        assert!((summary.avg_file_duration_ms() - 100.0).abs() < 0.01);
540    }
541
542    #[test]
543    fn analysis_summary_avg_file_duration_empty() {
544        let summary = AnalysisSummary::default();
545        assert!((summary.avg_file_duration_ms()).abs() < 0.01);
546    }
547
548    #[test]
549    fn analysis_event_constructors() {
550        let path = PathBuf::from("test.rs");
551
552        let event = AnalysisEvent::file_started(path.clone());
553        assert!(matches!(event, AnalysisEvent::FileStarted { .. }));
554
555        let event = AnalysisEvent::file_completed(path.clone(), 100);
556        assert!(matches!(
557            event,
558            AnalysisEvent::FileCompleted {
559                duration_ms: 100,
560                ..
561            }
562        ));
563
564        let event = AnalysisEvent::file_failed(path.clone(), "error");
565        assert!(matches!(event, AnalysisEvent::FileFailed { .. }));
566
567        let event = AnalysisEvent::parse_complete(path.clone(), 5);
568        assert!(matches!(
569            event,
570            AnalysisEvent::ParseComplete {
571                function_count: 5,
572                ..
573            }
574        ));
575
576        let event = AnalysisEvent::complexity_calculated(path.clone(), 10, 8);
577        assert!(matches!(
578            event,
579            AnalysisEvent::ComplexityCalculated {
580                cognitive: 10,
581                cyclomatic: 8,
582                ..
583            }
584        ));
585
586        let event = AnalysisEvent::debt_detected(path.clone(), Severity::Warning, "complexity");
587        assert!(matches!(
588            event,
589            AnalysisEvent::DebtItemDetected {
590                severity: Severity::Warning,
591                ..
592            }
593        ));
594
595        let event = AnalysisEvent::phase_started(AnalysisPhase::Parsing);
596        assert!(matches!(
597            event,
598            AnalysisEvent::PhaseStarted {
599                phase: AnalysisPhase::Parsing,
600                ..
601            }
602        ));
603
604        let event = AnalysisEvent::phase_completed(AnalysisPhase::Complexity, 50);
605        assert!(matches!(
606            event,
607            AnalysisEvent::PhaseCompleted {
608                phase: AnalysisPhase::Complexity,
609                duration_ms: 50
610            }
611        ));
612    }
613
614    #[test]
615    fn analysis_metrics_filter() {
616        let metrics = AnalysisMetrics::events(vec![
617            AnalysisEvent::file_started(PathBuf::from("a.rs")),
618            AnalysisEvent::file_completed(PathBuf::from("a.rs"), 100),
619            AnalysisEvent::file_started(PathBuf::from("b.rs")),
620            AnalysisEvent::file_completed(PathBuf::from("b.rs"), 200),
621        ]);
622
623        let started_only = metrics.filter(|e| matches!(e, AnalysisEvent::FileStarted { .. }));
624        assert_eq!(started_only.len(), 2);
625
626        let completed_only = metrics.filter(|e| matches!(e, AnalysisEvent::FileCompleted { .. }));
627        assert_eq!(completed_only.len(), 2);
628    }
629
630    #[test]
631    fn analysis_phase_display_name() {
632        assert_eq!(AnalysisPhase::Discovery.display_name(), "Discovery");
633        assert_eq!(AnalysisPhase::Parsing.display_name(), "Parsing");
634        assert_eq!(
635            AnalysisPhase::Complexity.display_name(),
636            "Complexity Analysis"
637        );
638        assert_eq!(
639            AnalysisPhase::DebtDetection.display_name(),
640            "Debt Detection"
641        );
642        assert_eq!(
643            AnalysisPhase::RiskAssessment.display_name(),
644            "Risk Assessment"
645        );
646        assert_eq!(AnalysisPhase::Reporting.display_name(), "Report Generation");
647    }
648
649    #[tokio::test]
650    async fn writer_effect_collects_single_event() {
651        // Emit a single event and collect it
652        let effect = tell_event::<(), ()>(AnalysisEvent::file_started(PathBuf::from("test.rs")));
653        let (_, metrics) = effect.run_writer(&()).await;
654
655        assert_eq!(metrics.len(), 1);
656        assert!(metrics
657            .iter()
658            .any(|e| matches!(e, AnalysisEvent::FileStarted { .. })));
659    }
660
661    #[tokio::test]
662    async fn writer_effect_with_chained_events() {
663        use stillwater::EffectExt;
664
665        // Use writer-specific and_then to chain two tell operations
666        let effect1 = tell_event::<(), ()>(AnalysisEvent::file_started(PathBuf::from("test.rs")));
667        let effect2 =
668            tell_event::<(), ()>(AnalysisEvent::file_completed(PathBuf::from("test.rs"), 50));
669
670        // Chain using EffectExt::and_then which composes effects
671        let effect = effect1.and_then(|_| effect2);
672
673        let (_, metrics) = effect.run_writer(&()).await;
674        assert_eq!(metrics.len(), 2);
675
676        let summary: AnalysisSummary = metrics.into();
677        assert_eq!(summary.files_processed, 1);
678        assert_eq!(summary.total_duration_ms, 50);
679    }
680
681    #[tokio::test]
682    async fn writer_effect_tap_tell_accumulates() {
683        use stillwater::effect::writer::WriterEffectExt;
684
685        // Use tap_tell to add events based on intermediate values
686        let effect = tell_event::<(), ()>(AnalysisEvent::phase_started(AnalysisPhase::Complexity))
687            .tap_tell(|_| {
688                AnalysisMetrics::event(AnalysisEvent::phase_completed(
689                    AnalysisPhase::Complexity,
690                    10,
691                ))
692            });
693
694        let (_, metrics) = effect.run_writer(&()).await;
695        assert_eq!(metrics.len(), 2);
696
697        // Verify we have both phase events
698        let has_started = metrics.iter().any(|e| {
699            matches!(
700                e,
701                AnalysisEvent::PhaseStarted {
702                    phase: AnalysisPhase::Complexity,
703                    ..
704                }
705            )
706        });
707        let has_completed = metrics.iter().any(|e| {
708            matches!(
709                e,
710                AnalysisEvent::PhaseCompleted {
711                    phase: AnalysisPhase::Complexity,
712                    ..
713                }
714            )
715        });
716        assert!(has_started);
717        assert!(has_completed);
718    }
719}