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}