use crate::turso::{self, row_text};
use crate::util::json;
use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::io;
use std::path::Path;
use std::sync::Arc;
use tokio::sync::OnceCell;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
use tracing_subscriber::fmt::MakeWriter;
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
use turso::{Row, Value, params};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogEntry {
pub timestamp: String,
pub level: String,
pub target: String,
pub message: String,
#[serde(default)]
pub fields: serde_json::Value,
#[serde(default)]
pub agent_id: String,
#[serde(default)]
pub agent_role: String,
#[serde(default)]
pub workspace: String,
}
#[derive(Clone, Debug)]
pub struct LogStore {
pub(crate) conn: crate::turso::Connection,
}
pub static LOG_STORE: OnceCell<LogStore> = OnceCell::const_new();
#[must_use]
pub fn store() -> &'static LogStore {
LOG_STORE
.get()
.expect("LOG_STORE not initialized — call init_tracing() first")
}
const LOGS_SCHEMA: &str = "\
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
level TEXT NOT NULL,
target TEXT NOT NULL,
message TEXT NOT NULL,
fields TEXT NOT NULL DEFAULT '{}',
agent_id TEXT NOT NULL DEFAULT '',
agent_role TEXT NOT NULL DEFAULT '',
workspace TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp);
CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(level);
CREATE INDEX IF NOT EXISTS idx_logs_target ON logs(target);
CREATE INDEX IF NOT EXISTS idx_logs_agent_role ON logs(agent_role);
CREATE INDEX IF NOT EXISTS idx_logs_agent_id ON logs(agent_id);
CREATE INDEX IF NOT EXISTS idx_logs_workspace ON logs(workspace);";
impl LogStore {
async fn open(db_path: &Path) -> anyhow::Result<Self> {
let conn = crate::turso::open_with_schema(db_path, LOGS_SCHEMA).await?;
Ok(Self { conn })
}
async fn insert(&self, entry: &LogEntry) -> anyhow::Result<()> {
self.conn
.execute(
"INSERT INTO logs (timestamp, level, target, message, fields, agent_id, agent_role, workspace) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
entry.timestamp.clone(),
entry.level.clone(),
entry.target.clone(),
entry.message.clone(),
serde_json::to_string(&entry.fields).unwrap_or_default(),
entry.agent_id.clone(),
entry.agent_role.clone(),
entry.workspace.clone(),
],
)
.await
.context("Failed to insert log entry")?;
Ok(())
}
fn build_query(filters: &LogQuery) -> (String, Vec<Value>) {
let (where_sql, mut values) = build_where_clause(filters);
let limit: i64 = i64::try_from(filters.limit.unwrap_or(100).min(1000)).unwrap_or(100);
let offset: i64 = i64::try_from(filters.offset.unwrap_or(0)).unwrap_or(0);
values.push(Value::Integer(limit));
values.push(Value::Integer(offset));
let sql = format!(
"SELECT timestamp, level, target, message, fields, agent_id, agent_role, workspace FROM logs {where_sql} ORDER BY id DESC LIMIT ? OFFSET ?",
);
(sql, values)
}
async fn count_matching(&self, filters: &LogQuery) -> Result<usize, ::turso::Error> {
let (where_sql, values) = build_where_clause(filters);
let sql = format!("SELECT COUNT(*) FROM logs {where_sql}");
let rows = self.conn.query(&sql, values).await?;
let count = match rows.into_iter().next() {
Some(row) => match row.get_value(0)? {
Value::Integer(n) => usize::try_from(n).unwrap_or(0),
_ => 0,
},
None => 0,
};
Ok(count)
}
pub async fn delete_older_than(&self, level: &str, cutoff: &str) -> anyhow::Result<u64> {
let n = self
.conn
.execute(
"DELETE FROM logs WHERE level = ?1 AND timestamp < ?2",
params![level, cutoff],
)
.await
.context("Failed to delete old log entries")?;
Ok(n)
}
pub async fn query(&self, filters: &LogQuery) -> anyhow::Result<(Vec<LogEntry>, usize)> {
let total = self.count_matching(filters).await?;
if total == 0 {
return Ok((vec![], 0));
}
let (data_sql, data_values) = Self::build_query(filters);
let rows = self
.conn
.query(&data_sql, data_values)
.await
.context("Data query failed")?;
let mut entries = Vec::new();
for row in rows {
entries.push(row_to_entry(&row)?);
}
Ok((entries, total))
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct LogQuery {
pub level: Option<String>,
pub target: Option<String>,
pub search: Option<String>,
pub since: Option<String>,
pub until: Option<String>,
pub limit: Option<usize>,
pub offset: Option<usize>,
pub agent_id: Option<String>,
pub agent_role: Option<String>,
pub workspace: Option<String>,
}
fn build_where_clause(filters: &LogQuery) -> (String, Vec<Value>) {
let mut conditions: Vec<String> = Vec::new();
let mut values: Vec<Value> = Vec::new();
if let Some(ref levels_str) = filters.level
&& !levels_str.is_empty()
{
let levels: Vec<Value> = levels_str
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| Value::Text(s.to_string()))
.collect();
if !levels.is_empty() {
conditions.push(format!(
"level IN ({})",
turso::sql_in_placeholders(levels.len()),
));
values.extend(levels);
}
}
if let Some(ref target) = filters.target {
conditions.push("target LIKE ?".into());
values.push(Value::Text(format!("{target}%")));
}
if let Some(ref search) = filters.search
&& !search.is_empty()
{
let val = Value::Text(format!("%{search}%"));
conditions.push("(target LIKE ? OR message LIKE ?)".into());
values.push(val.clone());
values.push(val);
}
if let Some(ref since) = filters.since {
conditions.push("timestamp >= ?".into());
values.push(Value::Text(since.clone()));
}
if let Some(ref until) = filters.until {
conditions.push("timestamp <= ?".into());
values.push(Value::Text(until.clone()));
}
if let Some(ref agent_id) = filters.agent_id {
conditions.push("agent_id LIKE ?".into());
values.push(Value::Text(format!("%{agent_id}%")));
}
if let Some(ref agent_role) = filters.agent_role {
conditions.push("agent_role = ?".into());
values.push(Value::Text(agent_role.clone()));
}
if let Some(ref workspace) = filters.workspace
&& !workspace.is_empty()
{
conditions.push("workspace = ?".into());
values.push(Value::Text(workspace.clone()));
}
if conditions.is_empty() {
(String::new(), values)
} else {
(format!("WHERE {}", conditions.join(" AND ")), values)
}
}
fn row_to_entry(row: &Row) -> anyhow::Result<LogEntry> {
let timestamp = row_text(row, 0)?;
let level = row_text(row, 1)?;
let target = row_text(row, 2)?;
let message = row_text(row, 3)?;
let fields_str = match row.get_value(4)? {
Value::Text(s) => s,
Value::Null => "{}".to_string(),
_ => anyhow::bail!("expected text for fields"),
};
let fields: serde_json::Value =
serde_json::from_str(&fields_str).unwrap_or(serde_json::Value::Null);
let agent_id = row_text(row, 5).unwrap_or_default();
let agent_role = row_text(row, 6).unwrap_or_default();
let workspace = row_text(row, 7).unwrap_or_default();
Ok(LogEntry {
timestamp,
level,
target,
message,
fields,
agent_id,
agent_role,
workspace,
})
}
pub async fn init_tracing(
storage_root: &Path,
) -> anyhow::Result<(Arc<LogStore>, tokio::sync::broadcast::Sender<String>)> {
let store = LogStore::open(&storage_root.join("db/logs.db")).await?;
LOG_STORE
.set(store.clone())
.map_err(|_| anyhow::anyhow!("LOG_STORE already initialized"))?;
let log_store = Arc::new(store);
let (log_tx, log_rx) = tokio::sync::mpsc::unbounded_channel();
let (broadcast_tx, _) = tokio::sync::broadcast::channel(256);
spawn_log_writer(Arc::clone(&log_store), log_rx, broadcast_tx.clone());
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new(
"info,turso_core=warn,tantivy=warn,ort=warn,fff_search=warn,fff_search::grep=error",
)
});
tracing_subscriber::registry()
.with(env_filter)
.with(
fmt::Layer::new()
.json()
.with_writer(make_log_writer(log_tx))
.with_ansi(false),
)
.init();
Ok((log_store, broadcast_tx))
}
const fn make_log_writer(tx: UnboundedSender<String>) -> LogWriter {
LogWriter { tx }
}
#[derive(Clone)]
struct LogWriter {
tx: UnboundedSender<String>,
}
impl io::Write for LogWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let line = String::from_utf8_lossy(buf).to_string();
let _ = self.tx.send(line);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl MakeWriter<'_> for LogWriter {
type Writer = Self;
fn make_writer(&self) -> Self::Writer {
self.clone()
}
}
fn spawn_log_writer(
store: Arc<LogStore>,
mut rx: UnboundedReceiver<String>,
broadcast: tokio::sync::broadcast::Sender<String>,
) {
tokio::spawn(async move {
while let Some(line) = rx.recv().await {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let Some(entry) = parse_tracing_json(trimmed) else {
continue;
};
let _ = broadcast.send(serde_json::to_string(&entry).unwrap_or_default());
if let Err(e) = store.insert(&entry).await {
let _ = e;
}
}
});
}
fn get_str_or_empty(val: &serde_json::Value, key: &str) -> String {
json::get_opt_str(val, key).unwrap_or("").to_string()
}
fn parse_tracing_json(line: &str) -> Option<LogEntry> {
let val: serde_json::Value = serde_json::from_str(line).ok()?;
let timestamp = get_str_or_empty(&val, "timestamp");
let level = get_str_or_empty(&val, "level");
let target = get_str_or_empty(&val, "target");
let mut fields = val
.get("fields")
.cloned()
.unwrap_or(serde_json::Value::Null);
let message = get_str_or_empty(&fields, "message");
if let Some(obj) = fields.as_object_mut() {
obj.remove("message");
}
let fields = if fields.as_object().is_some_and(serde_json::Map::is_empty) {
serde_json::Value::Null
} else {
fields
};
let (agent_id, agent_role, workspace) = extract_agent_from_span(&val);
Some(LogEntry {
timestamp,
level,
target,
message,
fields,
agent_id,
agent_role,
workspace,
})
}
fn extract_agent_fields(span: &serde_json::Value) -> (String, String, String) {
(
get_str_or_empty(span, "agent_id"),
get_str_or_empty(span, "role"),
get_str_or_empty(span, "workspace"),
)
}
fn extract_agent_from_span(val: &serde_json::Value) -> (String, String, String) {
if let Some(span) = val.get("span") {
let (id, role, ws) = extract_agent_fields(span);
if !id.is_empty() || !role.is_empty() || !ws.is_empty() {
return (id, role, ws);
}
}
if let Some(spans) = val.get("spans").and_then(|v| v.as_array())
&& let Some(last_span) = spans.last()
{
let (id, role, ws) = extract_agent_fields(last_span);
return (id, role, ws);
}
(String::new(), String::new(), String::new())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_tracing_json_full() {
let line = r#"{"timestamp":"2025-05-06T12:34:56.000000Z","level":"INFO","target":"mahbot::orchestrator","span":{"name":"agent","agent_id":"00000000-0000-0000-0000-000000000000","role":"lead","workspace":"/some/workspace"},"fields":{"message":"Hello world","key":"value"}}"#;
let entry = parse_tracing_json(line).unwrap();
assert_eq!(entry.timestamp, "2025-05-06T12:34:56.000000Z");
assert_eq!(entry.level, "INFO");
assert_eq!(entry.target, "mahbot::orchestrator");
assert_eq!(entry.message, "Hello world");
assert_eq!(entry.fields, serde_json::json!({"key": "value"}));
assert_eq!(entry.agent_id, "00000000-0000-0000-0000-000000000000");
assert_eq!(entry.agent_role, "lead");
assert_eq!(entry.workspace, "/some/workspace");
}
#[test]
fn test_parse_tracing_json_no_fields() {
let line = r#"{"timestamp":"2025-05-06T12:34:56.000000Z","level":"WARN","target":"test","fields":{"message":"warning"}}"#;
let entry = parse_tracing_json(line).unwrap();
assert_eq!(entry.message, "warning");
assert_eq!(entry.fields, serde_json::Value::Null);
assert_eq!(entry.agent_id, "");
assert_eq!(entry.agent_role, "");
assert_eq!(entry.workspace, "");
}
#[test]
fn test_parse_tracing_json_lenient() {
let entry = parse_tracing_json(r#"{"incomplete": true}"#).unwrap();
assert_eq!(entry.timestamp, "");
assert_eq!(entry.level, "");
assert_eq!(entry.target, "");
assert_eq!(entry.message, "");
assert_eq!(entry.fields, serde_json::Value::Null);
assert_eq!(entry.agent_id, "");
assert_eq!(entry.agent_role, "");
assert_eq!(entry.workspace, "");
}
#[test]
fn test_parse_tracing_json_with_span_agent() {
let line = r#"{"timestamp":"...","level":"INFO","target":"test","span":{"name":"agent","agent_id":"abc-123","role":"analyst"},"fields":{"message":"researching"}}"#;
let entry = parse_tracing_json(line).unwrap();
assert_eq!(entry.agent_id, "abc-123");
assert_eq!(entry.agent_role, "analyst");
assert_eq!(entry.workspace, "");
}
#[test]
fn test_parse_tracing_json_with_spans_array() {
let line = r#"{"timestamp":"...","level":"INFO","target":"test","spans":[{"name":"parent"},{"name":"agent","agent_id":"xyz-456","role":"coder","workspace":"/ws"}],"fields":{"message":"writing code"}}"#;
let entry = parse_tracing_json(line).unwrap();
assert_eq!(entry.agent_id, "xyz-456");
assert_eq!(entry.agent_role, "coder");
assert_eq!(entry.workspace, "/ws");
}
async fn seed_entries(store: &LogStore, entries: &[LogEntry]) {
for e in entries {
store.insert(e).await.unwrap();
}
}
#[test]
fn test_spawn_log_writer_writes_to_store() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("db/logs.db");
let store = Arc::new(LogStore::open(&db_path).await.unwrap());
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let (broadcast_tx, _) = tokio::sync::broadcast::channel(256);
spawn_log_writer(store.clone(), rx, broadcast_tx);
tx.send(
r#"{"timestamp":"2025-01-01T00:00:00Z","level":"INFO","target":"test","fields":{"message":"hi"}}"#
.to_string(),
)
.unwrap();
tx.send(
r#"{"timestamp":"2025-01-01T00:00:01Z","level":"ERROR","target":"test","fields":{"message":"oh no","err":"boom"}}"#
.to_string(),
)
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
drop(tx);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let (entries, total) = store.query(&LogQuery::default()).await.unwrap();
assert_eq!(total, 2);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].message, "oh no");
assert_eq!(entries[1].message, "hi");
});
}
#[test]
fn test_like_search_substring() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("db/logs.db");
let store = Arc::new(LogStore::open(&db_path).await.unwrap());
let entries = vec![
LogEntry {
timestamp: "2025-01-01T00:00:00Z".into(),
level: "INFO".into(),
target: "module_a".into(),
message: "processing request".into(),
fields: serde_json::Value::Null,
agent_id: String::new(),
agent_role: String::new(),
workspace: String::new(),
},
LogEntry {
timestamp: "2025-01-01T00:00:01Z".into(),
level: "ERROR".into(),
target: "module_b".into(),
message: "failed to process".into(),
fields: serde_json::Value::Null,
agent_id: String::new(),
agent_role: String::new(),
workspace: String::new(),
},
LogEntry {
timestamp: "2025-01-01T00:00:02Z".into(),
level: "INFO".into(),
target: "module_c".into(),
message: "started".into(),
fields: serde_json::Value::Null,
agent_id: String::new(),
agent_role: String::new(),
workspace: String::new(),
},
];
seed_entries(&store, &entries).await;
let (results, total) = store
.query(&LogQuery {
search: Some("proc".into()),
..Default::default()
})
.await
.unwrap();
assert_eq!(total, 2, "substring 'proc' should match both entries");
assert_eq!(results.len(), 2);
let (results, total) = store
.query(&LogQuery {
search: Some("request".into()),
..Default::default()
})
.await
.unwrap();
assert_eq!(total, 1);
assert_eq!(results[0].message, "processing request");
let (_results, total) = store
.query(&LogQuery {
search: Some("module".into()),
..Default::default()
})
.await
.unwrap();
assert_eq!(total, 3, "all targets contain 'module'");
});
}
#[test]
fn test_like_search_combined_filters() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("db/logs.db");
let store = Arc::new(LogStore::open(&db_path).await.unwrap());
let entries = vec![
LogEntry {
timestamp: "2025-01-01T00:00:00Z".into(),
level: "INFO".into(),
target: "mahbot::orchestrator".into(),
message: "processing request".into(),
fields: serde_json::Value::Null,
agent_id: String::new(),
agent_role: String::new(),
workspace: String::new(),
},
LogEntry {
timestamp: "2025-01-01T00:00:01Z".into(),
level: "ERROR".into(),
target: "mahbot::tools".into(),
message: "failed to process".into(),
fields: serde_json::json!({"code": 1}),
agent_id: String::new(),
agent_role: String::new(),
workspace: String::new(),
},
LogEntry {
timestamp: "2025-01-01T00:00:02Z".into(),
level: "INFO".into(),
target: "mahbot::api".into(),
message: "started".into(),
fields: serde_json::Value::Null,
agent_id: String::new(),
agent_role: String::new(),
workspace: String::new(),
},
];
seed_entries(&store, &entries).await;
let (results, total) = store
.query(&LogQuery {
level: Some("ERROR".into()),
search: Some("process".into()),
..Default::default()
})
.await
.unwrap();
assert_eq!(total, 1, "only ERROR log matching 'process'");
assert_eq!(results[0].message, "failed to process");
let (_results, total) = store
.query(&LogQuery {
target: Some("mahbot::tools".into()),
search: Some("process".into()),
..Default::default()
})
.await
.unwrap();
assert_eq!(total, 1, "only tools target entry matching 'process'");
let (_results, total) = store
.query(&LogQuery {
since: Some("2025-01-01T00:00:01Z".into()),
search: Some("process".into()),
..Default::default()
})
.await
.unwrap();
assert_eq!(total, 1, "only entry after timestamp matching 'process'");
});
}
#[test]
fn test_like_search_with_special_chars() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("db/logs.db");
let store = Arc::new(LogStore::open(&db_path).await.unwrap());
let entries = vec![
LogEntry {
timestamp: "2025-01-01T00:00:00Z".into(),
level: "INFO".into(),
target: "module_a".into(),
message: "processing `Hello ${name}` template".into(),
fields: serde_json::Value::Null,
agent_id: String::new(),
agent_role: String::new(),
workspace: String::new(),
},
LogEntry {
timestamp: "2025-01-01T00:00:01Z".into(),
level: "ERROR".into(),
target: "module_b".into(),
message: "normal log entry".into(),
fields: serde_json::Value::Null,
agent_id: String::new(),
agent_role: String::new(),
workspace: String::new(),
},
];
seed_entries(&store, &entries).await;
let (results, total) = store
.query(&LogQuery {
search: Some("template".into()),
..Default::default()
})
.await
.unwrap();
assert_eq!(total, 1, "LIKE should match partial word in message");
assert!(
results[0].message.contains("template"),
"should match the correct entry"
);
let (_results, total) = store
.query(&LogQuery {
search: None,
..Default::default()
})
.await
.unwrap();
assert_eq!(total, 2, "no search filter should return all entries");
});
}
}