use std::collections::BTreeMap;
use std::collections::HashMap;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SourceKind {
Docker,
Kubernetes,
Vm,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Stream {
Stdout,
Stderr,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Level {
Unknown,
Trace,
Debug,
Info,
Warn,
Error,
}
impl SourceKind {
pub fn parse(s: &str) -> Option<Self> {
match s {
"docker" => Some(SourceKind::Docker),
"kubernetes" => Some(SourceKind::Kubernetes),
"vm" => Some(SourceKind::Vm),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
SourceKind::Docker => "docker",
SourceKind::Kubernetes => "kubernetes",
SourceKind::Vm => "vm",
}
}
}
impl Stream {
pub fn parse(s: &str) -> Option<Self> {
match s {
"stdout" => Some(Stream::Stdout),
"stderr" => Some(Stream::Stderr),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Stream::Stdout => "stdout",
Stream::Stderr => "stderr",
}
}
}
impl Level {
pub fn parse(s: &str) -> Option<Self> {
match s {
"unknown" => Some(Level::Unknown),
"trace" => Some(Level::Trace),
"debug" => Some(Level::Debug),
"info" => Some(Level::Info),
"warn" => Some(Level::Warn),
"error" => Some(Level::Error),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Level::Unknown => "unknown",
Level::Trace => "trace",
Level::Debug => "debug",
Level::Info => "info",
Level::Warn => "warn",
Level::Error => "error",
}
}
pub fn sniff(line: &str) -> Level {
if let Some(level) = klog_level(line) {
return level;
}
let mut end = line.len().min(64);
while !line.is_char_boundary(end) {
end -= 1;
}
let upper = line[..end].to_ascii_uppercase();
if upper.contains("ERROR") || upper.contains("FATAL") || upper.contains("PANIC") {
Level::Error
} else if upper.contains("WARN") {
Level::Warn
} else if upper.contains("INFO") {
Level::Info
} else if upper.contains("DEBUG") {
Level::Debug
} else if upper.contains("TRACE") {
Level::Trace
} else {
Level::Unknown
}
}
pub fn sniff_after(line: &str, previous: Level) -> Level {
if previous != Level::Unknown && is_continuation(line) {
previous
} else {
Level::sniff(line)
}
}
}
fn klog_level(line: &str) -> Option<Level> {
let b = line.as_bytes();
if b.len() < 6 || !b[1..5].iter().all(u8::is_ascii_digit) || b[5] != b' ' {
return None;
}
match b[0] {
b'I' => Some(Level::Info),
b'W' => Some(Level::Warn),
b'E' | b'F' => Some(Level::Error),
_ => None,
}
}
fn is_continuation(line: &str) -> bool {
if line.starts_with([' ', '\t']) {
return true;
}
if ["Caused by:", "Suppressed:", "Traceback (most recent call last):"]
.iter()
.any(|p| line.starts_with(p))
{
return true;
}
let head = line.split(':').next().unwrap_or_default();
!head.is_empty()
&& head.chars().all(|c| c.is_ascii_alphanumeric() || "._$".contains(c))
&& ["Exception", "Error", "Throwable"].iter().any(|s| head.ends_with(s))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogRecord {
pub timestamp: SystemTime,
pub source: SourceKind,
pub origin: String,
pub stream: Stream,
pub level: Level,
pub body: String,
pub labels: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Target {
pub id: String,
pub name: String,
pub source: SourceKind,
pub state: String,
pub labels: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default)]
pub struct Filter {
needle: String,
pub min_level: Option<Level>,
pub from: Option<SystemTime>,
pub to: Option<SystemTime>,
pub skip_from: HashMap<String, SystemTime>,
}
impl Filter {
pub fn new(text: &str) -> Self {
Filter {
needle: text.to_lowercase(),
..Default::default()
}
}
pub fn is_empty(&self) -> bool {
self.needle.is_empty()
&& self.min_level.is_none()
&& self.from.is_none()
&& self.to.is_none()
&& self.skip_from.is_empty()
}
pub fn matches(&self, record: &LogRecord) -> bool {
if let Some(min) = self.min_level
&& record.level < min
{
return false;
}
if self.from.is_some_and(|from| record.timestamp < from) || self.to.is_some_and(|to| record.timestamp > to) {
return false;
}
if self
.skip_from
.get(&record.origin)
.is_some_and(|&skip| record.timestamp >= skip)
{
return false;
}
self.needle.is_empty()
|| record.body.to_lowercase().contains(&self.needle)
|| record.origin.to_lowercase().contains(&self.needle)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn record(origin: &str, body: &str) -> LogRecord {
LogRecord {
timestamp: SystemTime::UNIX_EPOCH,
source: SourceKind::Docker,
origin: origin.into(),
stream: Stream::Stdout,
level: Level::sniff(body),
body: body.into(),
labels: BTreeMap::new(),
}
}
#[test]
fn sniffs_levels() {
assert_eq!(Level::sniff("2026-10-01 ERROR db down"), Level::Error);
assert_eq!(Level::sniff("[warn] slow query"), Level::Warn);
assert_eq!(Level::sniff("hello"), Level::Unknown);
}
#[test]
fn sniffs_klog_levels() {
assert_eq!(
Level::sniff("I1002 23:47:01.123456 1 leader.go:42] renewed lease"),
Level::Info
);
assert_eq!(
Level::sniff("W1002 23:47:01.123456 1 reflector.go:561] watch closed"),
Level::Warn
);
assert_eq!(
Level::sniff("E1002 23:47:01.123456 1 controller.go:9] sync failed"),
Level::Error
);
assert_eq!(
Level::sniff("I1002 23:47:01.123456 1 x.go:1] retrying after error"),
Level::Info
);
assert_eq!(Level::sniff("Ingress controller started"), Level::Unknown);
}
#[test]
fn sniff_survives_multibyte_text_at_the_cut() {
let line = format!("{}€ ERROR", "a".repeat(63));
assert_eq!(Level::sniff(&line), Level::Unknown);
assert_eq!(Level::sniff("✓ ok"), Level::Unknown);
}
#[test]
fn stack_traces_keep_the_level_of_their_first_line() {
let lines = [
"2026-10-02T23:47:01.123Z ERROR 1 --- [nio-8080-exec-1] o.a.c.c.C.[dispatcherServlet] : Servlet failed",
"org.springframework.web.client.HttpServerErrorException$InternalServerError: 500 on POST",
"\tat org.springframework.web.client.DefaultResponseErrorHandler.handleError(DefaultResponseErrorHandler.java:89)",
"Caused by: java.net.SocketTimeoutException: Read timed out",
"\t... 16 more",
"2026-10-02T23:47:02.001Z INFO 1 --- [main] c.d.payment.App : recovered",
];
let mut level = Level::Unknown;
let levels: Vec<Level> = lines
.iter()
.map(|line| {
level = Level::sniff_after(line, level);
level
})
.collect();
assert_eq!(
levels,
[
Level::Error,
Level::Error,
Level::Error,
Level::Error,
Level::Error,
Level::Info
]
);
assert_eq!(Level::sniff_after("\tat x.y(Z.java:1)", Level::Unknown), Level::Unknown);
}
#[test]
fn filter_matches_body_and_origin_case_insensitively() {
let r = record("api-1", "GET /Health 200");
assert!(Filter::new("health").matches(&r));
assert!(Filter::new("API").matches(&r));
assert!(!Filter::new("postgres").matches(&r));
assert!(Filter::new("").matches(&r));
}
#[test]
fn filter_skips_lines_an_origin_already_holds() {
let at = |secs| SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs);
let mut filter = Filter::new("");
filter.skip_from.insert("api-1".into(), at(100));
let line = |origin, secs| LogRecord {
timestamp: at(secs),
..record(origin, "x")
};
assert!(filter.matches(&line("api-1", 99)));
assert!(!filter.matches(&line("api-1", 100)));
assert!(!filter.matches(&line("api-1", 500)));
assert!(filter.matches(&line("gone", 500)));
assert!(!filter.is_empty());
}
#[test]
fn enum_names_round_trip() {
for kind in [SourceKind::Docker, SourceKind::Kubernetes, SourceKind::Vm] {
assert_eq!(SourceKind::parse(kind.as_str()), Some(kind));
}
for stream in [Stream::Stdout, Stream::Stderr] {
assert_eq!(Stream::parse(stream.as_str()), Some(stream));
}
for level in [
Level::Unknown,
Level::Trace,
Level::Debug,
Level::Info,
Level::Warn,
Level::Error,
] {
assert_eq!(Level::parse(level.as_str()), Some(level));
}
assert_eq!(Level::parse("nope"), None);
}
#[test]
fn filter_respects_time_range() {
use std::time::Duration;
let at = |secs| {
let mut r = record("a", "x");
r.timestamp = SystemTime::UNIX_EPOCH + Duration::from_secs(secs);
r
};
let mut f = Filter::new("");
f.from = Some(SystemTime::UNIX_EPOCH + Duration::from_secs(10));
f.to = Some(SystemTime::UNIX_EPOCH + Duration::from_secs(20));
assert!(!f.is_empty());
assert!(!f.matches(&at(9)));
assert!(f.matches(&at(10)));
assert!(f.matches(&at(20)));
assert!(!f.matches(&at(21)));
}
#[test]
fn filter_respects_min_level() {
let mut f = Filter::new("");
f.min_level = Some(Level::Warn);
assert!(f.matches(&record("a", "ERROR boom")));
assert!(!f.matches(&record("a", "INFO ok")));
}
}