use chrono::{DateTime, Utc};
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RemoteLogEntry {
pub node_id: String,
pub level: String,
pub message: String,
pub source: String,
pub timestamp: DateTime<Utc>,
}
impl RemoteLogEntry {
pub fn new(
node_id: &str,
level: &str,
message: &str,
source: &str,
timestamp: DateTime<Utc>,
) -> Self {
Self {
node_id: node_id.to_string(),
level: level.to_string(),
message: message.to_string(),
source: source.to_string(),
timestamp,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LogQuery {
pub node_id: Option<String>,
pub level: Option<String>,
pub source: Option<String>,
pub since: Option<DateTime<Utc>>,
pub until: Option<DateTime<Utc>>,
pub limit: Option<usize>,
}
pub struct LogAggregator {
local_node_id: String,
entries: DashMap<String, Vec<RemoteLogEntry>>,
}
impl LogAggregator {
pub fn new(local_node_id: String) -> Self {
Self {
local_node_id,
entries: DashMap::new(),
}
}
pub fn log_local(&self, level: &str, message: &str, source: &str) {
let entry = RemoteLogEntry::new(&self.local_node_id, level, message, source, Utc::now());
self.entries
.entry(self.local_node_id.clone())
.or_default()
.push(entry);
}
pub fn add_remote(&self, entry: RemoteLogEntry) {
self.entries
.entry(entry.node_id.clone())
.or_default()
.push(entry);
}
pub fn add_remote_batch(&self, entries: Vec<RemoteLogEntry>) {
for entry in entries {
self.add_remote(entry);
}
}
pub fn query(&self, query: &LogQuery) -> Vec<RemoteLogEntry> {
let mut results: Vec<RemoteLogEntry> = Vec::new();
for entry_ref in self.entries.iter() {
let node_id = entry_ref.key();
let entries = entry_ref.value();
if query.node_id.as_ref().is_some_and(|n| node_id != n) {
continue;
}
for e in entries {
if query.level.as_ref().is_some_and(|l| e.level != *l) {
continue;
}
if query.source.as_ref().is_some_and(|s| e.source != *s) {
continue;
}
if query.since.is_some_and(|since| e.timestamp < since) {
continue;
}
if query.until.is_some_and(|until| e.timestamp > until) {
continue;
}
results.push(e.clone());
}
}
results.sort_by_key(|e| e.timestamp);
if let Some(limit) = query.limit {
results.truncate(limit);
}
results
}
pub fn entries_for_node(&self, node_id: &str) -> Vec<RemoteLogEntry> {
self.entries
.get(node_id)
.map(|v| v.value().clone())
.unwrap_or_default()
}
pub fn total_entries(&self) -> usize {
self.entries.iter().map(|e| e.value().len()).sum()
}
pub fn known_nodes(&self) -> Vec<String> {
self.entries.iter().map(|e| e.key().clone()).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
fn make_entry(node: &str, level: &str, msg: &str, offset_secs: i64) -> RemoteLogEntry {
RemoteLogEntry::new(
node,
level,
msg,
"test-module",
Utc::now() + Duration::seconds(offset_secs),
)
}
#[test]
fn log_local_entry() {
let agg = LogAggregator::new("node-1".into());
agg.log_local("info", "hello", "boot");
assert_eq!(agg.total_entries(), 1);
let entries = agg.entries_for_node("node-1");
assert_eq!(entries[0].message, "hello");
assert_eq!(entries[0].node_id, "node-1");
}
#[test]
fn add_remote_entry() {
let agg = LogAggregator::new("node-1".into());
let entry = make_entry("node-2", "warn", "remote warning", 0);
agg.add_remote(entry);
assert_eq!(agg.total_entries(), 1);
let entries = agg.entries_for_node("node-2");
assert_eq!(entries[0].node_id, "node-2");
}
#[test]
fn query_all_merged_by_timestamp() {
let agg = LogAggregator::new("node-1".into());
agg.add_remote(make_entry("node-2", "info", "second", 2));
agg.add_remote(make_entry("node-3", "info", "first", 1));
agg.add_remote(make_entry("node-1", "info", "third", 3));
let results = agg.query(&LogQuery::default());
assert_eq!(results.len(), 3);
assert_eq!(results[0].message, "first");
assert_eq!(results[1].message, "second");
assert_eq!(results[2].message, "third");
}
#[test]
fn query_filter_by_node() {
let agg = LogAggregator::new("node-1".into());
agg.add_remote(make_entry("node-1", "info", "local", 0));
agg.add_remote(make_entry("node-2", "info", "remote", 0));
let results = agg.query(&LogQuery {
node_id: Some("node-2".into()),
..Default::default()
});
assert_eq!(results.len(), 1);
assert_eq!(results[0].node_id, "node-2");
}
#[test]
fn query_filter_by_level() {
let agg = LogAggregator::new("node-1".into());
agg.add_remote(make_entry("node-1", "info", "info msg", 0));
agg.add_remote(make_entry("node-1", "error", "error msg", 0));
let results = agg.query(&LogQuery {
level: Some("error".into()),
..Default::default()
});
assert_eq!(results.len(), 1);
assert_eq!(results[0].level, "error");
}
#[test]
fn query_with_limit() {
let agg = LogAggregator::new("node-1".into());
for i in 0..10 {
agg.add_remote(make_entry("node-1", "info", &format!("msg-{i}"), i));
}
let results = agg.query(&LogQuery {
limit: Some(3),
..Default::default()
});
assert_eq!(results.len(), 3);
}
#[test]
fn query_filter_by_source() {
let agg = LogAggregator::new("n1".into());
agg.add_remote(RemoteLogEntry::new("n1", "info", "a", "boot", Utc::now()));
agg.add_remote(RemoteLogEntry::new("n1", "info", "b", "mesh", Utc::now()));
let results = agg.query(&LogQuery {
source: Some("mesh".into()),
..Default::default()
});
assert_eq!(results.len(), 1);
assert_eq!(results[0].source, "mesh");
}
#[test]
fn add_remote_batch() {
let agg = LogAggregator::new("n1".into());
let batch = vec![
make_entry("n2", "info", "a", 0),
make_entry("n3", "info", "b", 0),
];
agg.add_remote_batch(batch);
assert_eq!(agg.total_entries(), 2);
}
#[test]
fn known_nodes() {
let agg = LogAggregator::new("n1".into());
agg.log_local("info", "x", "y");
agg.add_remote(make_entry("n2", "info", "z", 0));
let mut nodes = agg.known_nodes();
nodes.sort();
assert_eq!(nodes, vec!["n1", "n2"]);
}
#[test]
fn empty_aggregator() {
let agg = LogAggregator::new("n1".into());
assert_eq!(agg.total_entries(), 0);
assert!(agg.query(&LogQuery::default()).is_empty());
assert!(agg.known_nodes().is_empty());
}
#[test]
fn remote_log_entry_serialization() {
let entry = RemoteLogEntry::new("n1", "info", "msg", "src", Utc::now());
let json = serde_json::to_string(&entry).unwrap();
let deser: RemoteLogEntry = serde_json::from_str(&json).unwrap();
assert_eq!(deser.node_id, "n1");
assert_eq!(deser.message, "msg");
}
}