Skip to main content

ralph_workflow/json_parser/health/
parser_health.rs

1// Parser health statistics.
2//
3// Contains the ParserHealth struct for tracking event processing statistics.
4
5use crate::logger::Colors;
6
7/// Parser health statistics
8#[derive(Debug, Default, Clone, Copy)]
9pub struct ParserHealth {
10    /// Total number of events processed
11    pub total_events: u64,
12    /// Number of events successfully parsed and displayed
13    pub parsed_events: u64,
14    /// Number of partial/delta events (streaming content displayed incrementally)
15    pub partial_events: u64,
16    /// Number of events ignored (malformed JSON, unknown events, etc.)
17    pub ignored_events: u64,
18    /// Number of control events (state management, no user output)
19    pub control_events: u64,
20    /// Number of unknown event types (valid JSON but unhandled)
21    pub unknown_events: u64,
22    /// Number of JSON parse errors (malformed JSON)
23    pub parse_errors: u64,
24}
25
26impl ParserHealth {
27    /// Create a new health tracker
28    #[must_use]
29    pub fn new() -> Self {
30        Self::default()
31    }
32
33    /// Record a parsed event
34    pub const fn record_parsed(&mut self) {
35        self.total_events = self.total_events.saturating_add(1);
36        self.parsed_events = self.parsed_events.saturating_add(1);
37    }
38
39    /// Record an ignored event
40    pub const fn record_ignored(&mut self) {
41        self.total_events = self.total_events.saturating_add(1);
42        self.ignored_events = self.ignored_events.saturating_add(1);
43    }
44
45    /// Record an unknown event type (valid JSON but unhandled)
46    ///
47    /// Unknown events are valid JSON that the parser deserialized successfully
48    /// but doesn't have specific handling for. These should not trigger health
49    /// warnings as they represent future/new event types, not parser errors.
50    pub const fn record_unknown_event(&mut self) {
51        self.total_events = self.total_events.saturating_add(1);
52        self.unknown_events = self.unknown_events.saturating_add(1);
53        self.ignored_events = self.ignored_events.saturating_add(1);
54    }
55
56    /// Record a parse error (malformed JSON)
57    pub const fn record_parse_error(&mut self) {
58        self.total_events = self.total_events.saturating_add(1);
59        self.parse_errors = self.parse_errors.saturating_add(1);
60        self.ignored_events = self.ignored_events.saturating_add(1);
61    }
62
63    /// Record a control event (state management with no user-facing output)
64    ///
65    /// Control events are valid JSON that represent state transitions
66    /// rather than user-facing content. They should not be counted as
67    /// "ignored" for health monitoring purposes.
68    pub const fn record_control_event(&mut self) {
69        self.total_events = self.total_events.saturating_add(1);
70        self.control_events = self.control_events.saturating_add(1);
71    }
72
73    /// Record a partial/delta event (streaming content displayed incrementally)
74    ///
75    /// Partial events represent streaming content that is shown to the user
76    /// in real-time as deltas. These are NOT errors and should not trigger
77    /// health warnings. They are tracked separately to show streaming activity.
78    pub const fn record_partial_event(&mut self) {
79        self.total_events = self.total_events.saturating_add(1);
80        self.partial_events = self.partial_events.saturating_add(1);
81    }
82
83    /// Get the percentage of parse errors (excluding unknown events)
84    ///
85    /// Returns percentage using integer-safe arithmetic to avoid precision loss warnings.
86    #[must_use]
87    pub fn parse_error_percentage(&self) -> f64 {
88        if self.total_events == 0 {
89            return 0.0;
90        }
91        // Use integer arithmetic: (errors * 10000) / total, then divide by 100.0
92        // This gives two decimal places of precision without casting u64 to f64
93        let percent_hundredths = self
94            .parse_errors
95            .saturating_mul(10000)
96            .checked_div(self.total_events)
97            .unwrap_or(0);
98        // Convert to f64 only after scaling down to a reasonable range
99        // percent_hundredths is at most 10000 (100% * 100), which fits precisely in f64
100        let scaled: u32 = u32::try_from(percent_hundredths)
101            .unwrap_or(u32::MAX)
102            .min(10000);
103        f64::from(scaled) / 100.0
104    }
105
106    /// Get the percentage of parse errors as a rounded integer.
107    ///
108    /// This is for display purposes where a whole number is sufficient.
109    #[must_use]
110    pub fn parse_error_percentage_int(&self) -> u32 {
111        if self.total_events == 0 {
112            return 0;
113        }
114        // (errors * 100) / total gives us the integer percentage
115        self.parse_errors
116            .saturating_mul(100)
117            .checked_div(self.total_events)
118            .and_then(|v| u32::try_from(v).ok())
119            .unwrap_or(0)
120            .min(100)
121    }
122
123    /// Check if the parser health is concerning
124    ///
125    /// Only returns true if there are actual parse errors (malformed JSON),
126    /// not just unknown event types. Unknown events are valid JSON that we
127    /// don't have specific handling for, which is not a health concern.
128    #[must_use]
129    pub fn is_concerning(&self) -> bool {
130        self.total_events > 10 && self.parse_error_percentage() > 50.0
131    }
132
133    /// Get a warning message if health is concerning
134    #[must_use]
135    pub fn warning(&self, parser_name: &str, colors: Colors) -> Option<String> {
136        if !self.is_concerning() {
137            return None;
138        }
139        Some(self.format_warning_message(parser_name, colors))
140    }
141
142    fn format_warning_message(&self, parser_name: &str, colors: Colors) -> String {
143        let has_extra_events =
144            self.unknown_events > 0 || self.control_events > 0 || self.partial_events > 0;
145        if has_extra_events {
146            self.format_warning_with_extra_events(parser_name, colors)
147        } else {
148            format_basic_warning(
149                parser_name,
150                colors,
151                self.parse_errors,
152                self.parse_error_percentage_int(),
153                self.total_events,
154            )
155        }
156    }
157
158    fn format_warning_with_extra_events(&self, parser_name: &str, colors: Colors) -> String {
159        format!(
160            "{}[Parser Health Warning]{} {} parser has {} parse errors ({}% of {} events). \
161             Also encountered {} unknown event types (valid JSON but unhandled), \
162             {} control events (state management), \
163             and {} partial events (streaming deltas). \
164             This may indicate a parser mismatch. Consider using a different json_parser in your agent config.",
165            colors.yellow(),
166            colors.reset(),
167            parser_name,
168            self.parse_errors,
169            self.parse_error_percentage_int(),
170            self.total_events,
171            self.unknown_events,
172            self.control_events,
173            self.partial_events
174        )
175    }
176}
177
178fn format_basic_warning(
179    parser_name: &str,
180    colors: Colors,
181    parse_errors: u64,
182    error_pct: u32,
183    total_events: u64,
184) -> String {
185    format!(
186        "{}[Parser Health Warning]{} {} parser has {} parse errors ({}% of {} events). \
187         This may indicate malformed JSON output. Consider using a different json_parser in your agent config.",
188        colors.yellow(),
189        colors.reset(),
190        parser_name,
191        parse_errors,
192        error_pct,
193        total_events
194    )
195}