Skip to main content

rlg_report/
lib.rs

1// lib.rs
2// Copyright © 2024-2026 RustLogs (RLG). All rights reserved.
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Aggregation helpers for `rlg` log streams.
6//!
7//! The companion `rlg-report` binary wires these helpers to a CLI.
8//! The library surface is exposed so dashboards / web UIs / tests
9//! can build their own front-ends on top.
10//!
11//! ```
12//! use rlg_report::Report;
13//!
14//! let lines = [
15//!     r#"{"session_id":1,"time":"t","level":"INFO","component":"svc","description":"hi","format":"JSON","attributes":{}}"#,
16//!     r#"{"session_id":2,"time":"t","level":"ERROR","component":"db","description":"boom","format":"JSON","attributes":{"latency_ms":120}}"#,
17//!     r#"{"session_id":3,"time":"t","level":"ERROR","component":"db","description":"boom","format":"JSON","attributes":{"latency_ms":80}}"#,
18//! ];
19//! let report = Report::from_lines(lines.iter().copied());
20//! assert_eq!(report.total, 3);
21//! assert_eq!(report.count_by_level.get("ERROR").copied(), Some(2));
22//! assert_eq!(report.count_by_component.get("db").copied(), Some(2));
23//! assert_eq!(report.top_descriptions[0].0, "boom");
24//! ```
25
26#![forbid(unsafe_code)]
27#![deny(missing_docs)]
28
29use rlg::log_level::LogLevel;
30use rlg_cli::parse_record;
31use std::collections::BTreeMap;
32
33/// Aggregated digest of a log stream.
34#[derive(Debug, Default, Clone)]
35pub struct Report {
36    /// Total parseable records seen.
37    pub total: u64,
38    /// Records that failed to parse as canonical JSON `Log`.
39    pub unparseable: u64,
40    /// Count of records grouped by level (string keys: `INFO`, `ERROR`, …).
41    pub count_by_level: BTreeMap<String, u64>,
42    /// Count grouped by `component`.
43    pub count_by_component: BTreeMap<String, u64>,
44    /// Top descriptions by frequency, descending. Top 10 by default.
45    pub top_descriptions: Vec<(String, u64)>,
46    /// Latency stats (ms) extracted from the `latency_ms` /
47    /// `http.latency_ms` attribute when present. `None` when no
48    /// records carried such an attribute.
49    pub latency: Option<LatencyStats>,
50}
51
52/// p50 / p95 / p99 / max latency percentiles, all in milliseconds.
53#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
54pub struct LatencyStats {
55    /// Number of records that carried a `latency_ms` attribute.
56    pub samples: usize,
57    /// 50th percentile (median).
58    pub p50: u64,
59    /// 95th percentile.
60    pub p95: u64,
61    /// 99th percentile.
62    pub p99: u64,
63    /// Maximum.
64    pub max: u64,
65}
66
67impl Report {
68    /// Aggregate a stream of JSON-shaped record lines into a [`Report`].
69    /// Lines that don't parse as canonical JSON `Log` are counted as
70    /// `unparseable` and otherwise ignored.
71    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    /// Same as [`Self::from_lines`] but lets the caller pick how many
79    /// top descriptions to keep.
80    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    /// Render the report as a human-readable text table.
131    #[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    /// Render the report as JSON.
166    ///
167    /// # Errors
168    /// Returns `serde_json::Error` only if the serialiser fails,
169    /// which cannot happen for the report's fixed shape.
170    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    /// Count of records at level ERROR-and-above.
189    #[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        // "boom" appears twice, "hi" and "crash" once each.
268        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        // p50 of [42, 80, 120] is 80; p95 / p99 land on 120.
279        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}