diff --git a/Cargo.lock b/Cargo.lock index c5a22ca..9fdcf64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1425,6 +1425,18 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.13" @@ -1668,6 +1680,7 @@ dependencies = [ "jsonrpsee", "prometheus-http-client", "rand", + "regex", "serde", "serde_json", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index c2594e1..b59e521 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ hyper-util = { version = "0.1", features = ["tokio", "server", "server-auto"] } jsonrpsee = { version = "0.26", features = ["macros", "async-client"] } plotters = { version = "0.3", default-features = false, features = ["bitmap_backend", "bitmap_encoder", "ttf", "datetime", "area_series", "line_series", "full_palette"] } rand = "0.9" +regex = "1" reqwest = { version = "0.12", default-features = false, features = ["json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/signal-gateway/Cargo.toml b/signal-gateway/Cargo.toml index 296e66f..339d480 100644 --- a/signal-gateway/Cargo.toml +++ b/signal-gateway/Cargo.toml @@ -25,6 +25,7 @@ http-body = { workspace = true } http-body-util = { workspace = true } humantime = { workspace = true } jsonrpsee = { workspace = true } +regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/signal-gateway/src/log_message.rs b/signal-gateway/src/log_message.rs index c43ba54..6cd3284 100644 --- a/signal-gateway/src/log_message.rs +++ b/signal-gateway/src/log_message.rs @@ -266,6 +266,35 @@ impl Origin { } } +/// A compiled regex that can be cloned and deserialized. +#[derive(Clone)] +pub struct CompiledRegex(regex::Regex); + +impl CompiledRegex { + /// Check if the regex matches the given text. + pub fn is_match(&self, text: &str) -> bool { + self.0.is_match(text) + } +} + +impl std::fmt::Debug for CompiledRegex { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "/{}/", self.0.as_str()) + } +} + +impl<'de> Deserialize<'de> for CompiledRegex { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let pattern = String::deserialize(deserializer)?; + regex::Regex::new(&pattern) + .map(CompiledRegex) + .map_err(serde::de::Error::custom) + } +} + /// Filter criteria for matching log messages. /// /// All non-empty fields must match for the filter to pass. @@ -274,6 +303,12 @@ pub struct LogFilter { /// If non-empty, the message must contain this substring. #[serde(default)] pub msg_contains: String, + /// If non-empty, the message must equal this value exactly. + #[serde(default)] + pub msg_equals: String, + /// If set, the message must match this regex. + #[serde(default)] + pub msg_regex: Option, /// If non-empty, the module path must equal this value exactly. #[serde(default)] pub module_equals: String, @@ -291,6 +326,12 @@ impl std::fmt::Debug for LogFilter { if !self.msg_contains.is_empty() { s.field("msg_contains", &self.msg_contains); } + if !self.msg_equals.is_empty() { + s.field("msg_equals", &self.msg_equals); + } + if let Some(regex) = &self.msg_regex { + s.field("msg_regex", regex); + } if !self.module_equals.is_empty() { s.field("module_equals", &self.module_equals); } @@ -313,6 +354,16 @@ impl LogFilter { return false; } + if !self.msg_equals.is_empty() && *log_msg.msg != *self.msg_equals { + return false; + } + + if let Some(regex) = &self.msg_regex { + if !regex.is_match(&log_msg.msg) { + return false; + } + } + if !self.module_equals.is_empty() { match log_msg.module_path.as_deref() { Some(module) if module == self.module_equals.as_str() => {} @@ -391,4 +442,70 @@ mod tests { assert!(origin2.matches_filter("app@host")); assert!(!origin2.matches_filter("app@other")); } + + fn make_log_msg(msg: &str) -> LogMessage { + LogMessage::builder(Level::ERROR, msg) + .module_path("test::module") + .file("test.rs") + .line("42") + .build() + } + + #[test] + fn test_log_filter_msg_contains() { + let filter: LogFilter = serde_json::from_str(r#"{"msg_contains": "error"}"#).unwrap(); + assert!(filter.matches(&make_log_msg("an error occurred"))); + assert!(filter.matches(&make_log_msg("error"))); + assert!(!filter.matches(&make_log_msg("warning message"))); + } + + #[test] + fn test_log_filter_msg_equals() { + let filter: LogFilter = serde_json::from_str(r#"{"msg_equals": "exact match"}"#).unwrap(); + assert!(filter.matches(&make_log_msg("exact match"))); + assert!(!filter.matches(&make_log_msg("exact match with extra"))); + assert!(!filter.matches(&make_log_msg("not exact match"))); + } + + #[test] + fn test_log_filter_msg_regex() { + let filter: LogFilter = serde_json::from_str(r#"{"msg_regex": "error \\d+"}"#).unwrap(); + assert!(filter.matches(&make_log_msg("error 123"))); + assert!(filter.matches(&make_log_msg("an error 456 occurred"))); + assert!(!filter.matches(&make_log_msg("error"))); + assert!(!filter.matches(&make_log_msg("warning 123"))); + } + + #[test] + fn test_log_filter_combined() { + // msg_contains AND module_equals must both match + let filter: LogFilter = serde_json::from_str( + r#"{"msg_contains": "error", "module_equals": "test::module"}"#, + ) + .unwrap(); + assert!(filter.matches(&make_log_msg("an error occurred"))); + + // Wrong module + let mut msg = make_log_msg("an error occurred"); + msg.module_path = Some("other::module".into()); + assert!(!filter.matches(&msg)); + + // Wrong message + let msg2 = make_log_msg("warning message"); + assert!(!filter.matches(&msg2)); + } + + #[test] + fn test_log_filter_empty_matches_all() { + let filter: LogFilter = serde_json::from_str(r#"{}"#).unwrap(); + assert!(filter.matches(&make_log_msg("anything"))); + assert!(filter.matches(&make_log_msg(""))); + } + + #[test] + fn test_compiled_regex_debug() { + let filter: LogFilter = serde_json::from_str(r#"{"msg_regex": "test.*pattern"}"#).unwrap(); + let debug_str = format!("{:?}", filter); + assert!(debug_str.contains("/test.*pattern/")); + } }