1#![forbid(unsafe_code)]
27#![deny(missing_docs)]
28
29use rlg::log_level::LogLevel;
30use rlg_cli::parse_record;
31use std::collections::BTreeMap;
32
33#[derive(Debug, Default, Clone)]
35pub struct Report {
36 pub total: u64,
38 pub unparseable: u64,
40 pub count_by_level: BTreeMap<String, u64>,
42 pub count_by_component: BTreeMap<String, u64>,
44 pub top_descriptions: Vec<(String, u64)>,
46 pub latency: Option<LatencyStats>,
50}
51
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
54pub struct LatencyStats {
55 pub samples: usize,
57 pub p50: u64,
59 pub p95: u64,
61 pub p99: u64,
63 pub max: u64,
65}
66
67impl Report {
68 pub fn from_lines<'a, I>(lines: I) -> Self
72 where
73 I: IntoIterator<Item = &'a str>,
74 {
75 Self::from_lines_with_top(lines, 10)
76 }
77
78 pub fn from_lines_with_top<'a, I>(lines: I, top_n: usize) -> Self
81 where
82 I: IntoIterator<Item = &'a str>,
83 {
84 let mut report = Self::default();
85 let mut descriptions: BTreeMap<String, u64> = BTreeMap::new();
86 let mut latencies: Vec<u64> = Vec::new();
87
88 for line in lines {
89 let trimmed = line.trim();
90 if trimmed.is_empty() {
91 continue;
92 }
93 let Ok(record) = parse_record(trimmed) else {
94 report.unparseable += 1;
95 continue;
96 };
97 report.total += 1;
98 *report
99 .count_by_level
100 .entry(record.level.to_string())
101 .or_insert(0) += 1;
102 *report
103 .count_by_component
104 .entry(record.component.to_string())
105 .or_insert(0) += 1;
106 *descriptions
107 .entry(record.description.clone())
108 .or_insert(0) += 1;
109 for key in ["latency_ms", "http.latency_ms"] {
110 if let Some(v) = record.attributes.get(key)
111 && let Some(ms) = v.as_u64()
112 {
113 latencies.push(ms);
114 }
115 }
116 }
117
118 let mut top: Vec<(String, u64)> =
119 descriptions.into_iter().collect();
120 top.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
121 top.truncate(top_n);
122 report.top_descriptions = top;
123
124 if !latencies.is_empty() {
125 report.latency = Some(percentiles(&mut latencies));
126 }
127 report
128 }
129
130 #[must_use]
132 pub fn to_text(&self) -> String {
133 use std::fmt::Write;
134 let mut out = String::new();
135 let _ = writeln!(
136 out,
137 "── rlg report ───────────────────────────────────────────"
138 );
139 let _ = writeln!(out, "total records: {}", self.total);
140 let _ =
141 writeln!(out, "unparseable lines: {}", self.unparseable);
142 let _ = writeln!(out, "\n── by level ─────────────────");
143 for (level, count) in &self.count_by_level {
144 let _ = writeln!(out, " {level:<10} {count}");
145 }
146 let _ = writeln!(out, "\n── by component ─────────────");
147 for (component, count) in &self.count_by_component {
148 let _ = writeln!(out, " {component:<10} {count}");
149 }
150 let _ = writeln!(out, "\n── top descriptions ─────────");
151 for (desc, count) in &self.top_descriptions {
152 let _ = writeln!(out, " {count:>5} {desc}");
153 }
154 if let Some(l) = &self.latency {
155 let _ = writeln!(out, "\n── latency (ms) ─────────────");
156 let _ = writeln!(out, " samples {}", l.samples);
157 let _ = writeln!(out, " p50 {}", l.p50);
158 let _ = writeln!(out, " p95 {}", l.p95);
159 let _ = writeln!(out, " p99 {}", l.p99);
160 let _ = writeln!(out, " max {}", l.max);
161 }
162 out
163 }
164
165 pub fn to_json(&self) -> Result<String, serde_json::Error> {
171 let value = serde_json::json!({
172 "total": self.total,
173 "unparseable": self.unparseable,
174 "count_by_level": self.count_by_level,
175 "count_by_component": self.count_by_component,
176 "top_descriptions": self.top_descriptions,
177 "latency": self.latency.map(|l| serde_json::json!({
178 "samples": l.samples,
179 "p50": l.p50,
180 "p95": l.p95,
181 "p99": l.p99,
182 "max": l.max,
183 })),
184 });
185 serde_json::to_string_pretty(&value)
186 }
187
188 #[must_use]
190 pub fn error_count(&self) -> u64 {
191 let threshold = LogLevel::ERROR.to_numeric();
192 self.count_by_level
193 .iter()
194 .filter_map(|(name, count)| {
195 name.parse::<LogLevel>()
196 .ok()
197 .filter(|l| l.to_numeric() >= threshold)
198 .map(|_| *count)
199 })
200 .sum()
201 }
202}
203
204#[allow(clippy::cast_possible_truncation)]
205fn percentiles(values: &mut [u64]) -> LatencyStats {
206 values.sort_unstable();
207 let samples = values.len();
208 let pick = |q: f64| -> u64 {
209 let idx = (((samples as f64) * q).ceil() as usize)
210 .saturating_sub(1)
211 .min(samples - 1);
212 values[idx]
213 };
214 LatencyStats {
215 samples,
216 p50: pick(0.50),
217 p95: pick(0.95),
218 p99: pick(0.99),
219 max: *values.last().unwrap(),
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 const INFO: &str = r#"{"session_id":1,"time":"t","level":"INFO","component":"svc","description":"hi","format":"JSON","attributes":{}}"#;
228 const ERROR_BOOM_1: &str = r#"{"session_id":2,"time":"t","level":"ERROR","component":"db","description":"boom","format":"JSON","attributes":{"latency_ms":120}}"#;
229 const ERROR_BOOM_2: &str = r#"{"session_id":3,"time":"t","level":"ERROR","component":"db","description":"boom","format":"JSON","attributes":{"latency_ms":80}}"#;
230 const FATAL: &str = r#"{"session_id":4,"time":"t","level":"FATAL","component":"db","description":"crash","format":"JSON","attributes":{}}"#;
231 const HTTP: &str = r#"{"session_id":5,"time":"t","level":"INFO","component":"api","description":"GET /","format":"JSON","attributes":{"http.latency_ms":42}}"#;
232
233 #[test]
234 fn empty_input_is_a_zero_report() {
235 let r = Report::from_lines(std::iter::empty::<&str>());
236 assert_eq!(r.total, 0);
237 assert!(r.count_by_level.is_empty());
238 assert!(r.latency.is_none());
239 }
240
241 #[test]
242 fn counts_records_by_level_and_component() {
243 let r = Report::from_lines([
244 INFO,
245 ERROR_BOOM_1,
246 ERROR_BOOM_2,
247 FATAL,
248 HTTP,
249 ]);
250 assert_eq!(r.total, 5);
251 assert_eq!(r.count_by_level.get("INFO").copied(), Some(2));
252 assert_eq!(r.count_by_level.get("ERROR").copied(), Some(2));
253 assert_eq!(r.count_by_level.get("FATAL").copied(), Some(1));
254 assert_eq!(r.count_by_component.get("db").copied(), Some(3));
255 assert_eq!(r.count_by_component.get("svc").copied(), Some(1));
256 assert_eq!(r.count_by_component.get("api").copied(), Some(1));
257 }
258
259 #[test]
260 fn top_descriptions_ranks_by_frequency() {
261 let r = Report::from_lines([
262 INFO,
263 ERROR_BOOM_1,
264 ERROR_BOOM_2,
265 FATAL,
266 ]);
267 assert_eq!(r.top_descriptions[0].0, "boom");
269 assert_eq!(r.top_descriptions[0].1, 2);
270 }
271
272 #[test]
273 fn latency_percentiles_are_sorted() {
274 let r = Report::from_lines([ERROR_BOOM_1, ERROR_BOOM_2, HTTP]);
275 let l = r.latency.expect("latency stats present");
276 assert_eq!(l.samples, 3);
277 assert_eq!(l.max, 120);
278 assert_eq!(l.p50, 80);
280 assert_eq!(l.p95, 120);
281 assert_eq!(l.p99, 120);
282 }
283
284 #[test]
285 fn unparseable_lines_are_counted_separately() {
286 let r =
287 Report::from_lines([INFO, "not json at all", "", FATAL]);
288 assert_eq!(r.total, 2);
289 assert_eq!(r.unparseable, 1);
290 }
291
292 #[test]
293 fn to_text_contains_section_headers() {
294 let r = Report::from_lines([INFO, ERROR_BOOM_1]);
295 let text = r.to_text();
296 assert!(text.contains("by level"));
297 assert!(text.contains("by component"));
298 assert!(text.contains("top descriptions"));
299 assert!(text.contains("latency"));
300 }
301
302 #[test]
303 fn to_json_round_trips() {
304 let r = Report::from_lines([INFO, ERROR_BOOM_1]);
305 let json = r.to_json().unwrap();
306 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
307 assert_eq!(v["total"], 2);
308 assert_eq!(v["count_by_level"]["INFO"], 1);
309 }
310
311 #[test]
312 fn error_count_sums_error_and_above() {
313 let r = Report::from_lines([
314 INFO,
315 ERROR_BOOM_1,
316 ERROR_BOOM_2,
317 FATAL,
318 ]);
319 assert_eq!(r.error_count(), 3);
320 }
321
322 #[test]
323 fn top_n_can_be_clamped() {
324 let lines = [INFO, ERROR_BOOM_1, ERROR_BOOM_2, FATAL, HTTP];
325 let r = Report::from_lines_with_top(lines, 1);
326 assert_eq!(r.top_descriptions.len(), 1);
327 }
328
329 #[test]
330 fn http_latency_attribute_is_picked_up() {
331 let r = Report::from_lines([HTTP]);
332 let l = r.latency.expect("latency present");
333 assert_eq!(l.samples, 1);
334 assert_eq!(l.max, 42);
335 }
336}