Skip to main content

roboticus_api/api/routes/
health.rs

1//! Health and logs API handlers.
2
3use axum::{extract::State, response::IntoResponse};
4use serde_json::Value;
5
6use super::{AppState, internal_err};
7
8/// Structured log entry returned by the logs API (from tracing JSON lines).
9#[derive(Debug, serde::Serialize)]
10pub struct LogEntry {
11    pub timestamp: String,
12    pub level: String,
13    pub message: String,
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub target: Option<String>,
16}
17
18pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
19    let config = state.config.read().await;
20    let primary_model = config.models.primary.clone();
21    let fallbacks = config.models.fallbacks.clone();
22    let agent_name = config.agent.name.clone();
23    drop(config);
24
25    let llm = state.llm.read().await;
26    let current_model = llm.router.select_model().to_string();
27    drop(llm);
28
29    axum::Json(serde_json::json!({
30        "status": "ok",
31        "version": env!("CARGO_PKG_VERSION"),
32        "agent": agent_name,
33        "uptime_seconds": state.started_at.elapsed().as_secs(),
34        "models": {
35            "primary": primary_model,
36            "current": current_model,
37            "fallbacks": fallbacks,
38        },
39    }))
40}
41
42pub async fn get_logs(
43    State(state): State<AppState>,
44    axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
45) -> impl IntoResponse {
46    let lines_limit = params
47        .get("lines")
48        .and_then(|v| v.parse::<usize>().ok())
49        .unwrap_or(100)
50        .min(10_000);
51    let level_filter = params
52        .get("level")
53        .map(|s| s.to_lowercase())
54        .filter(|s| matches!(s.as_str(), "info" | "warn" | "error" | "debug" | "trace"));
55
56    let log_dir = {
57        let config = state.config.read().await;
58        config.server.log_dir.clone()
59    };
60
61    let entries = match read_log_entries(&log_dir, lines_limit, level_filter.as_deref()) {
62        Ok(entries) => entries,
63        Err(e) => return Err(internal_err(&e)),
64    };
65    Ok(axum::Json(serde_json::json!({ "entries": entries })))
66}
67
68/// Read the most recent log file in `log_dir`, tail up to `lines` lines, optionally filter by level.
69pub fn read_log_entries(
70    log_dir: &std::path::Path,
71    lines: usize,
72    level_filter: Option<&str>,
73) -> Result<Vec<LogEntry>, String> {
74    let mut log_files: Vec<std::path::PathBuf> = match std::fs::read_dir(log_dir) {
75        Ok(rd) => rd,
76        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
77        Err(e) => return Err(format!("failed to read log directory: {}", e)),
78    }
79    .filter_map(|e| {
80        e.inspect_err(|e| tracing::warn!("skipping unreadable log dir entry: {e}"))
81            .ok()
82    })
83    .map(|e| e.path())
84    .filter(|p| {
85        p.is_file()
86            && p.file_name()
87                .and_then(|n| n.to_str())
88                .is_some_and(|n| n.starts_with("roboticus.log") || n.ends_with(".log"))
89    })
90    .collect();
91    if log_files.is_empty() {
92        return Ok(vec![]);
93    }
94    log_files.sort_by(|a, b| {
95        let ma = std::fs::metadata(a)
96            .and_then(|m| m.modified())
97            .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
98        let mb = std::fs::metadata(b)
99            .and_then(|m| m.modified())
100            .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
101        ma.cmp(&mb).reverse().then_with(|| a.cmp(b).reverse())
102    });
103    let path = log_files
104        .first()
105        .cloned()
106        .ok_or_else(|| "no log file path (empty list after sort)".to_string())?;
107    let content =
108        std::fs::read_to_string(&path).map_err(|e| format!("failed to read log file: {}", e))?;
109    let raw_lines: Vec<&str> = content.lines().rev().take(lines).collect();
110    let raw_lines: Vec<&str> = raw_lines.into_iter().rev().collect();
111    let mut entries = Vec::with_capacity(raw_lines.len());
112    for line in raw_lines {
113        let line = line.trim();
114        if line.is_empty() {
115            continue;
116        }
117        let obj: Value = match serde_json::from_str(line) {
118            Ok(v) => v,
119            Err(_) => continue,
120        };
121        let level = obj
122            .get("level")
123            .and_then(|v| v.as_str())
124            .unwrap_or("")
125            .to_lowercase();
126        if let Some(filter) = level_filter
127            && level != filter
128        {
129            continue;
130        }
131        let message = obj
132            .get("fields")
133            .and_then(|f| f.get("message"))
134            .and_then(|m| m.as_str())
135            .unwrap_or("")
136            .to_string();
137        let timestamp = obj
138            .get("timestamp")
139            .and_then(|t| t.as_str())
140            .unwrap_or("")
141            .to_string();
142        let target = obj.get("target").and_then(|t| t.as_str()).map(String::from);
143        entries.push(LogEntry {
144            timestamp,
145            level,
146            message,
147            target,
148        });
149    }
150    Ok(entries)
151}