zeroclaw_gateway/
api_logs.rs1use 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 pub next_cursor: Option<(String, String)>,
49 pub at_end: bool,
51 pub daemon_started_at: String,
54 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 ¶ms {
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}