Skip to main content

shep_core/config/
level.rs

1//! What one app's log lines look like, when shep's own reading of them is
2//! wrong.
3//!
4//! A client classifies a line so an operator can filter by level. Its
5//! built-in reading looks for a level word near the start of the line, which
6//! an app writing `{"severity":"ERROR"}` or `11:02:03.144 [main] SEVERE`
7//! never satisfies. [`LevelRule`] lets the app say what its own lines look
8//! like instead: an ordered list of patterns, first match wins.
9
10use core::fmt;
11
12use serde::{Deserialize, Serialize};
13
14/// Largest compiled program a [`LevelRule`] pattern may produce.
15///
16/// 1 MiB, the bound `ProcessSelector` already puts on a peer-supplied
17/// regex. A pattern reaches this crate from a Flockfile and from
18/// `Request::Start`, so the memory one costs is not this process's to
19/// choose, and regex's own default is unbounded in practice.
20const PATTERN_SIZE_LIMIT: usize = 1 << 20;
21
22/// Most rules one app may declare.
23///
24/// [`PATTERN_SIZE_LIMIT`] bounds what ONE pattern may compile to, and a list
25/// of N patterns costs N of those, so leaving the count open left the same
26/// argument half made: the memory is still not this process's to choose.
27///
28/// Sixty-four because five levels need five entries to name each once, and an
29/// app whose lines announce a level several ways needs a small multiple of
30/// that. Twelve times over is past anything a real Flockfile reaches.
31///
32/// The count is what a client pays per redraw, not just once:
33/// `Classifier::new` compiles the whole list on every `visible()` call rather
34/// than holding it, which is the right call while the list is short and is
35/// only defensible because of this ceiling. At a measured 78 us for five
36/// rules, a full list costs about 1 ms a frame; unbounded, a redraw is
37/// unbounded too.
38const MAX_LEVEL_RULES: usize = 64;
39
40/// The level one log line announces.
41///
42/// Ordered lowest first, so `level >= minimum` reads the way an operator
43/// setting a floor expects. `Display` renders the spelling a Flockfile
44/// writes.
45//
46// Not `config::LogLevel`, which is the shepherd's own verbosity. This is a
47// level a sheep claims for one of its own lines, and shep only reads it.
48// wire format: changing these strings is a breaking change.
49//
50// No `#[non_exhaustive]` (IR-20). These five are the universal set every
51// logging library agrees on, so growth is not anticipated; a sixth would
52// also change what every operator's stored filter means, which is a break
53// whatever this attribute says.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
55#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
56#[serde(rename_all = "snake_case")]
57pub enum LineLevel {
58    /// The lowest, and the only one an app has to opt into emitting.
59    Trace,
60    /// Below `Info`, and the usual floor for an app's own noise.
61    Debug,
62    /// The default an operator reads when nothing is filtered.
63    Info,
64    /// Something an operator should look at, without the sheep being broken.
65    Warn,
66    /// The highest. A line saying it is fatal is not saying something milder
67    /// than one saying error.
68    Error,
69}
70
71impl fmt::Display for LineLevel {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        f.write_str(match self {
74            Self::Trace => "trace",
75            Self::Debug => "debug",
76            Self::Info => "info",
77            Self::Warn => "warn",
78            Self::Error => "error",
79        })
80    }
81}
82
83/// One rule: the lines this pattern matches carry this level.
84///
85/// The pattern is a regex, matched against the whole line, exactly as
86/// written: `(?i)` makes one case-insensitive, and nothing is anchored for
87/// you.
88//
89// `Debug` is derived (IR-41). A pattern and a level describe an app's
90// output rather than carrying any of it.
91// wire format: changing field names is a breaking change.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
94#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))]
95pub struct LevelRule {
96    /// The regex a line must match for this rule to claim it.
97    pub pattern: String,
98    /// The level a matching line is given.
99    pub level: LineLevel,
100}
101
102/// A compiled [`LevelRule`] list, ready to classify lines.
103///
104/// Build one with [`Self::compile`]. Rules are tried in the order they were
105/// declared and the first match wins, so an operator orders them rather than
106/// discovering which of two overlapping patterns a map happened to hold
107/// first.
108#[derive(Debug)]
109pub struct LevelMatcher {
110    rules: Vec<(regex::Regex, LineLevel)>,
111}
112
113impl LevelMatcher {
114    /// Compiles `rules` in order.
115    ///
116    /// An empty slice compiles to an empty matcher, which classifies
117    /// nothing. What a caller does with that is its own decision: a client
118    /// reading an app that declared no rules falls back to its own reading
119    /// of the line.
120    ///
121    /// # Errors
122    /// - [`LevelRuleError::TooManyRules`] if `rules` is longer than this
123    ///   crate's ceiling on how many one app may declare, which the error
124    ///   carries as `limit`.
125    /// - [`LevelRuleError::EmptyPattern`] if a rule's pattern is empty.
126    /// - [`LevelRuleError::BadPattern`] if a pattern does not compile, or
127    ///   compiles to a program over this crate's 1 MiB bound.
128    pub fn compile(rules: &[LevelRule]) -> Result<Self, LevelRuleError> {
129        if rules.len() > MAX_LEVEL_RULES {
130            return Err(LevelRuleError::TooManyRules {
131                count: rules.len(),
132                limit: MAX_LEVEL_RULES,
133            });
134        }
135        let mut compiled = Vec::with_capacity(rules.len());
136        for rule in rules {
137            if rule.pattern.is_empty() {
138                return Err(LevelRuleError::EmptyPattern { level: rule.level });
139            }
140            let regex = regex::RegexBuilder::new(&rule.pattern)
141                .size_limit(PATTERN_SIZE_LIMIT)
142                .build()
143                .map_err(|err| LevelRuleError::BadPattern {
144                    pattern: rule.pattern.clone(),
145                    reason: err.to_string(),
146                })?;
147            compiled.push((regex, rule.level));
148        }
149        Ok(Self { rules: compiled })
150    }
151
152    /// The level the first matching rule gives `line`, or `None` when none
153    /// matches.
154    ///
155    /// `None` is an ordinary answer and never means "below the minimum": an
156    /// app's output is arbitrary text and most of it announces no level at
157    /// all.
158    #[must_use]
159    pub fn level_of(&self, line: &str) -> Option<LineLevel> {
160        self.rules
161            .iter()
162            .find(|(regex, _)| regex.is_match(line))
163            .map(|(_, level)| *level)
164    }
165}
166
167/// Why a [`LevelRule`] list was refused.
168///
169/// `#[non_exhaustive]`: shep-core is published, and a rule grammar that
170/// grows a second kind of pattern would add a variant here with no version
171/// to warn an out-of-tree match about it.
172#[derive(Debug, Clone, PartialEq, Eq)]
173#[non_exhaustive]
174pub enum LevelRuleError {
175    /// A rule's pattern is empty. An empty regex matches every line, so the
176    /// rule would claim the whole feed and leave every rule after it dead.
177    EmptyPattern {
178        /// The level that rule would have given, so the refusal names which
179        /// entry to edit.
180        level: LineLevel,
181    },
182    /// More rules than this crate's ceiling on how many one app may declare.
183    /// Each costs a compiled regex, and a client recompiles the whole list
184    /// on every redraw, so the count is a per-frame cost. `limit` carries
185    /// the ceiling rather than the doc naming a private const.
186    TooManyRules {
187        /// How many the app declared.
188        count: usize,
189        /// The ceiling it passed.
190        limit: usize,
191    },
192    /// A pattern does not compile, or compiles to a program over the size
193    /// bound this crate puts on a pattern it did not write.
194    BadPattern {
195        /// The pattern as the user wrote it.
196        pattern: String,
197        /// regex's own rendered reason.
198        reason: String,
199    },
200}
201
202impl fmt::Display for LevelRuleError {
203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        match self {
205            Self::EmptyPattern { level } => write!(
206                f,
207                "the `{level}` level rule has an empty pattern, which would match every line"
208            ),
209            Self::TooManyRules { count, limit } => write!(
210                f,
211                "{count} level rules, over the {limit} one app may declare: each is a compiled \
212                 regex a client rebuilds on every redraw"
213            ),
214            Self::BadPattern { pattern, reason } => {
215                write!(f, "invalid level rule pattern `{pattern}`: {reason}")
216            }
217        }
218    }
219}
220
221impl core::error::Error for LevelRuleError {}
222
223#[cfg(test)]
224mod tests {
225    /// fails if the count stops being bounded. `PATTERN_SIZE_LIMIT` caps one
226    /// pattern and a list costs one of those per entry, so without this the
227    /// memory a Flockfile can ask a client to spend per redraw is open.
228    #[test]
229    fn a_list_longer_than_the_ceiling_is_refused() {
230        let rule = |n: usize| LevelRule {
231            pattern: format!("^{n} "),
232            level: LineLevel::Info,
233        };
234        let at_ceiling: Vec<LevelRule> = (0..MAX_LEVEL_RULES).map(rule).collect();
235        assert!(
236            LevelMatcher::compile(&at_ceiling).is_ok(),
237            "the ceiling itself is allowed"
238        );
239
240        let over: Vec<LevelRule> = (0..=MAX_LEVEL_RULES).map(rule).collect();
241        let err = LevelMatcher::compile(&over).expect_err("one past it is refused");
242        assert_eq!(
243            err,
244            LevelRuleError::TooManyRules {
245                count: MAX_LEVEL_RULES + 1,
246                limit: MAX_LEVEL_RULES,
247            }
248        );
249        // The refusal has to name both numbers: an operator trimming a list
250        // needs to know how far over it is.
251        let text = err.to_string();
252        assert!(text.contains("65"), "{text}");
253        assert!(text.contains("64"), "{text}");
254    }
255
256    use super::*;
257
258    #[test]
259    fn levels_order_from_trace_up_to_error() {
260        assert!(LineLevel::Trace < LineLevel::Debug);
261        assert!(LineLevel::Debug < LineLevel::Info);
262        assert!(LineLevel::Info < LineLevel::Warn);
263        assert!(LineLevel::Warn < LineLevel::Error);
264    }
265
266    /// The wire spelling, which a Flockfile writes and an older peer reads.
267    #[test]
268    fn a_level_serializes_as_its_lowercase_name() {
269        let rule = LevelRule {
270            pattern: "x".to_string(),
271            level: LineLevel::Warn,
272        };
273        assert_eq!(
274            serde_json::to_string(&rule).unwrap(),
275            r#"{"pattern":"x","level":"warn"}"#
276        );
277    }
278
279    #[test]
280    fn a_rule_claims_the_lines_its_pattern_matches() {
281        let matcher = LevelMatcher::compile(&[LevelRule {
282            pattern: r#""severity":"ERROR""#.to_string(),
283            level: LineLevel::Error,
284        }])
285        .unwrap();
286        assert_eq!(
287            matcher.level_of(r#"{"severity":"ERROR","msg":"boom"}"#),
288            Some(LineLevel::Error)
289        );
290        assert_eq!(matcher.level_of(r#"{"severity":"INFO"}"#), None);
291    }
292
293    /// The whole reason the list is ordered rather than a map: two patterns
294    /// can match one line, and the operator decides which wins.
295    #[test]
296    fn the_first_matching_rule_wins() {
297        let rules = |first, second| {
298            vec![
299                LevelRule {
300                    pattern: "boom".to_string(),
301                    level: first,
302                },
303                LevelRule {
304                    pattern: "b".to_string(),
305                    level: second,
306                },
307            ]
308        };
309        let matcher = LevelMatcher::compile(&rules(LineLevel::Error, LineLevel::Debug)).unwrap();
310        assert_eq!(matcher.level_of("boom"), Some(LineLevel::Error));
311
312        let reversed = LevelMatcher::compile(&rules(LineLevel::Debug, LineLevel::Error)).unwrap();
313        assert_eq!(reversed.level_of("boom"), Some(LineLevel::Debug));
314    }
315
316    /// A line no rule matches is unclassified, not the lowest level: a
317    /// filter that treated it as a miss would hide a bare `println!`.
318    #[test]
319    fn a_line_no_rule_matches_has_no_level() {
320        let matcher = LevelMatcher::compile(&[LevelRule {
321            pattern: "^ERROR".to_string(),
322            level: LineLevel::Error,
323        }])
324        .unwrap();
325        assert_eq!(matcher.level_of("listening on 8080"), None);
326    }
327
328    #[test]
329    fn no_rules_compiles_to_a_matcher_that_classifies_nothing() {
330        let matcher = LevelMatcher::compile(&[]).unwrap();
331        assert_eq!(matcher.level_of("ERROR everything is on fire"), None);
332    }
333
334    /// Case is the pattern's own business. Refusing to fold it is what lets
335    /// an app tell `ERROR` apart from a message mentioning an error.
336    #[test]
337    fn a_pattern_matches_case_exactly_unless_it_asks_not_to() {
338        let exact = LevelMatcher::compile(&[LevelRule {
339            pattern: "^ERROR".to_string(),
340            level: LineLevel::Error,
341        }])
342        .unwrap();
343        assert_eq!(exact.level_of("error: nope"), None);
344
345        let folded = LevelMatcher::compile(&[LevelRule {
346            pattern: "(?i)^error".to_string(),
347            level: LineLevel::Error,
348        }])
349        .unwrap();
350        assert_eq!(folded.level_of("error: nope"), Some(LineLevel::Error));
351    }
352
353    #[test]
354    fn an_empty_pattern_is_refused() {
355        let err = LevelMatcher::compile(&[LevelRule {
356            pattern: String::new(),
357            level: LineLevel::Warn,
358        }])
359        .unwrap_err();
360        assert_eq!(
361            err,
362            LevelRuleError::EmptyPattern {
363                level: LineLevel::Warn
364            }
365        );
366        assert!(err.to_string().contains("warn"), "{err}");
367    }
368
369    #[test]
370    fn a_pattern_regex_refuses_is_refused() {
371        let err = LevelMatcher::compile(&[LevelRule {
372            pattern: "[unclosed".to_string(),
373            level: LineLevel::Error,
374        }])
375        .unwrap_err();
376        assert!(
377            matches!(&err, LevelRuleError::BadPattern { pattern, .. } if pattern == "[unclosed"),
378            "{err:?}"
379        );
380    }
381
382    /// The bound exists because a pattern arrives from a peer. Without it
383    /// one Flockfile line can cost the process hundreds of megabytes.
384    #[test]
385    fn a_pattern_over_the_size_bound_is_refused() {
386        let err = LevelMatcher::compile(&[LevelRule {
387            pattern: format!("(a{}){{10000}}", "|b".repeat(100_000)),
388            level: LineLevel::Error,
389        }])
390        .unwrap_err();
391        assert!(matches!(err, LevelRuleError::BadPattern { .. }), "{err:?}");
392    }
393}