faucet_cli/serve/handlers/
logs.rs1use crate::serve::error::ServeError;
11use crate::serve::logs::{LogEvent, log_events};
12use crate::serve::state::ServerState;
13use axum::extract::{Path, Query, State};
14use axum::response::sse::{Event, KeepAlive};
15use axum::response::{IntoResponse, Response, Sse};
16use futures::StreamExt;
17use serde::Deserialize;
18use std::time::Duration;
19use tokio::sync::broadcast;
20
21const KEEP_ALIVE_SECS: u64 = 15;
23
24const DEFAULT_LOG_LIMIT: usize = 1_000;
26const MAX_LOG_LIMIT: usize = 10_000;
27
28#[derive(Debug, Deserialize)]
30pub struct LogQuery {
31 #[serde(default)]
33 pub format: Option<String>,
34 #[serde(default)]
36 pub after: Option<u64>,
37 #[serde(default)]
39 pub limit: Option<usize>,
40}
41
42pub async fn stream_logs(
52 State(state): State<ServerState>,
53 Path(id): Path<String>,
54 Query(q): Query<LogQuery>,
55) -> Result<Response, ServeError> {
56 match q.format.as_deref() {
57 None => stream_logs_sse(state, id)
58 .await
59 .map(IntoResponse::into_response),
60 Some("jsonl") | Some("text") => persisted_logs(state, id, q).await,
61 Some(other) => Err(ServeError::BadConfig(format!(
62 "unknown log format '{other}'; use 'jsonl' or 'text' (or omit for the SSE stream)"
63 ))),
64 }
65}
66
67async fn persisted_logs(
69 state: ServerState,
70 id: String,
71 q: LogQuery,
72) -> Result<Response, ServeError> {
73 let known = state
75 .history()
76 .get(&id)
77 .await
78 .map_err(|e| ServeError::Internal(e.to_string()))?
79 .is_some();
80 if !known {
81 return Err(ServeError::NotFound);
82 }
83 let limit = q.limit.unwrap_or(DEFAULT_LOG_LIMIT).clamp(1, MAX_LOG_LIMIT);
84 let page = state
85 .history()
86 .list_run_logs(&id, q.after, limit)
87 .await
88 .map_err(|e| ServeError::Internal(e.to_string()))?;
89
90 if q.format.as_deref() == Some("text") {
91 let mut body = String::new();
92 for l in &page.lines {
93 body.push_str(&l.line);
94 body.push('\n');
95 }
96 if page.truncated {
97 body.push_str("… (earlier lines truncated: per-run cap reached)\n");
98 }
99 return Ok((
100 [(
101 axum::http::header::CONTENT_TYPE,
102 "text/plain; charset=utf-8",
103 )],
104 body,
105 )
106 .into_response());
107 }
108
109 let mut body = String::new();
111 for l in &page.lines {
112 body.push_str(&serde_json::to_string(l).unwrap_or_default());
113 body.push('\n');
114 }
115 if page.truncated {
116 body.push_str(r#"{"truncated":true}"#);
117 body.push('\n');
118 }
119 Ok((
120 [(axum::http::header::CONTENT_TYPE, "application/x-ndjson")],
121 body,
122 )
123 .into_response())
124}
125
126async fn stream_logs_sse(
128 state: ServerState,
129 id: String,
130) -> Result<Sse<impl futures::Stream<Item = Result<Event, std::convert::Infallible>>>, ServeError> {
131 let (snapshot, rx, ended) = match state.log_hub().reader(&id) {
132 Some(reader) => reader,
133 None => {
134 let known = state
137 .history()
138 .get(&id)
139 .await
140 .map_err(|e| ServeError::Internal(e.to_string()))?
141 .is_some();
142 if !known {
143 return Err(ServeError::NotFound);
144 }
145 (Vec::new(), broadcast::channel(1).1, true)
147 }
148 };
149
150 let stream = log_events(snapshot, rx, ended)
151 .map(|ev| Ok::<Event, std::convert::Infallible>(to_sse_event(ev)));
152 Ok(
153 Sse::new(stream)
154 .keep_alive(KeepAlive::new().interval(Duration::from_secs(KEEP_ALIVE_SECS))),
155 )
156}
157
158fn to_sse_event(ev: LogEvent) -> Event {
160 match ev {
161 LogEvent::Log(line) => Event::default().event("log").data(line),
162 LogEvent::Truncated(n) => Event::default().event("truncated").data(format!(
163 "{n} log line(s) dropped; rely on the persisted logs (?format=jsonl) or the centralized log sink"
164 )),
165 LogEvent::End => Event::default().event("end").data("done"),
166 }
167}