Skip to main content

zeroclaw_gateway/
api_logs.rs

1//! `GET /api/logs` — paginated query over the persisted JSONL log.
2//!
3//! Thin HTTP adapter over [`zeroclaw_log::load_page`]. Pagination is
4//! cursor-based: responses include `next_cursor: (timestamp, id)` which
5//! callers pass back as `until_ts` / `until_id` to fetch older events.
6//!
7//! Top-level query params: `since_ts`, `until_ts`, `until_id`, `action`,
8//! `category`, `outcome`, `severity_min`, `trace_id`, `q`,
9//! `hide_internal`, `limit`. Every other `?key=value` is treated as a
10//! per-attribution exact-match (`zeroclaw.<key> == value`), driven by
11//! [`zeroclaw_log::is_attribution_field`]. Adding a new attribution
12//! field anywhere in the schema requires no changes here.
13
14use std::collections::{BTreeMap, HashMap};
15
16use axum::{
17    Json,
18    extract::{Query, State},
19    http::{HeaderMap, StatusCode},
20    response::{IntoResponse, Response},
21};
22use serde::Serialize;
23use zeroclaw_log::{
24    ATTRIBUTION_FIELDS, COMPOSITE_PREFIXES, LogFilter, LogPage, is_attribution_field,
25};
26
27use super::AppState;
28use super::api::require_auth;
29
30const TOP_LEVEL_PARAMS: &[&str] = &[
31    "since_ts",
32    "until_ts",
33    "until_id",
34    "action",
35    "category",
36    "outcome",
37    "severity_min",
38    "trace_id",
39    "q",
40    "hide_internal",
41    "limit",
42];
43
44#[derive(Debug, Serialize)]
45pub struct LogsResponse {
46    pub events: Vec<serde_json::Value>,
47    /// `Some((timestamp, id))` when more older events may exist.
48    pub next_cursor: Option<(String, String)>,
49    /// True when the file was fully scanned for this filter.
50    pub at_end: bool,
51    /// Daemon start time so callers can implement "since daemon start"
52    /// without an extra `/api/status` round-trip.
53    pub daemon_started_at: String,
54    /// Canonical attribution-field names — `ATTRIBUTION_FIELDS` plus, for
55    /// each entry in `COMPOSITE_PREFIXES`, the bare prefix and its
56    /// `<prefix>_type` / `<prefix>_alias` decomposed keys. The dashboard
57    /// reads this instead of enumerating schema fields client-side.
58    pub attribution_keys: Vec<String>,
59}
60
61fn attribution_keys_for_response() -> Vec<String> {
62    let mut keys: Vec<String> = ATTRIBUTION_FIELDS
63        .iter()
64        .map(|name| (*name).to_string())
65        .collect();
66    for prefix in COMPOSITE_PREFIXES {
67        keys.push((*prefix).to_string());
68        keys.push(format!("{prefix}_type"));
69        keys.push(format!("{prefix}_alias"));
70    }
71    keys
72}
73
74pub async fn handle_api_logs(
75    State(state): State<AppState>,
76    headers: HeaderMap,
77    Query(params): Query<HashMap<String, String>>,
78) -> Response {
79    if let Err(e) = require_auth(&state, &headers) {
80        return e.into_response();
81    }
82
83    let Some(path) = zeroclaw_log::current_log_path() else {
84        return Json(LogsResponse {
85            events: Vec::new(),
86            next_cursor: None,
87            at_end: true,
88            daemon_started_at: zeroclaw_runtime::health::daemon_started_at(),
89            attribution_keys: attribution_keys_for_response(),
90        })
91        .into_response();
92    };
93
94    let take = |key: &str| -> Option<String> {
95        params.get(key).map(String::from).filter(|s| !s.is_empty())
96    };
97
98    let severity_min = params
99        .get("severity_min")
100        .and_then(|raw| raw.parse::<u8>().ok());
101    let hide_internal = params
102        .get("hide_internal")
103        .map(|raw| matches!(raw.as_str(), "true" | "1" | "yes"))
104        .unwrap_or(false);
105    let limit = params
106        .get("limit")
107        .and_then(|raw| raw.parse::<usize>().ok())
108        .unwrap_or(200);
109
110    let mut field_eq: BTreeMap<String, String> = BTreeMap::new();
111    for (key, value) in &params {
112        if TOP_LEVEL_PARAMS.contains(&key.as_str()) {
113            continue;
114        }
115        if !is_attribution_field(key) {
116            return (
117                StatusCode::BAD_REQUEST,
118                Json(serde_json::json!({
119                    "error": format!("unknown query parameter: {key}"),
120                })),
121            )
122                .into_response();
123        }
124        if value.is_empty() {
125            continue;
126        }
127        field_eq.insert(key.clone(), value.clone());
128    }
129
130    let filter = LogFilter {
131        since_ts: take("since_ts"),
132        until_ts: take("until_ts"),
133        until_id: take("until_id"),
134        action: take("action"),
135        category: take("category"),
136        outcome: take("outcome"),
137        severity_min,
138        trace_id: take("trace_id"),
139        q: take("q"),
140        hide_internal,
141        field_eq,
142    };
143
144    let LogPage {
145        events,
146        next_cursor,
147        at_end,
148    } = match zeroclaw_log::load_page(&path, &filter, limit) {
149        Ok(page) => page,
150        Err(err) => {
151            return (
152                StatusCode::INTERNAL_SERVER_ERROR,
153                Json(serde_json::json!({
154                    "error": format!("log read failed: {err:#}"),
155                })),
156            )
157                .into_response();
158        }
159    };
160
161    let events_json: Vec<serde_json::Value> = events
162        .into_iter()
163        .filter_map(|event| serde_json::to_value(event).ok())
164        .collect();
165
166    Json(LogsResponse {
167        events: events_json,
168        next_cursor,
169        at_end,
170        daemon_started_at: zeroclaw_runtime::health::daemon_started_at(),
171        attribution_keys: attribution_keys_for_response(),
172    })
173    .into_response()
174}