Skip to main content

faucet_cli/serve/handlers/
logs.rs

1//! `GET /v1/runs/{id}/logs` — a run's captured log lines.
2//!
3//! Default (no `format`): a Server-Sent Events stream that replays the ephemeral
4//! ring then forwards the live tail until the run ends. With `?format=jsonl` or
5//! `?format=text` (#529): the **persisted** logs from the history backend,
6//! paginated with `?after=<seq>&limit=<n>` — fetchable any time after the run
7//! ends, past the SSE drain window. Thin glue over [`crate::serve::logs`] and
8//! `RunHistory::list_run_logs`.
9
10use 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
21/// Keep-alive comment interval — defeats idle-timeout proxies on a quiet stream.
22const KEEP_ALIVE_SECS: u64 = 15;
23
24/// Default / maximum page size for the persisted-log read.
25const DEFAULT_LOG_LIMIT: usize = 1_000;
26const MAX_LOG_LIMIT: usize = 10_000;
27
28/// Query params for `GET /v1/runs/{id}/logs`.
29#[derive(Debug, Deserialize)]
30pub struct LogQuery {
31    /// `jsonl` / `text` select the persisted read; absent = the SSE stream.
32    #[serde(default)]
33    pub format: Option<String>,
34    /// Return only lines with `seq > after` (persisted read pagination).
35    #[serde(default)]
36    pub after: Option<u64>,
37    /// Max lines per page (persisted read), capped at `MAX_LOG_LIMIT`.
38    #[serde(default)]
39    pub limit: Option<usize>,
40}
41
42/// `GET /v1/runs/{id}/logs`.
43///
44/// - Default → `text/event-stream`: `event: log` / `truncated` / `end`.
45/// - `?format=jsonl` → `application/x-ndjson`, one `{seq,ts,level,line}` per line,
46///   oldest-first, `?after`/`?limit` paginated; a trailing `{"truncated":true}`
47///   record when earlier lines were dropped by the per-run cap.
48/// - `?format=text` → `text/plain`, the lines concatenated.
49///
50/// 404 if the run is entirely unknown.
51pub 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
67/// The persisted (durable) log read (#529).
68async fn persisted_logs(
69    state: ServerState,
70    id: String,
71    q: LogQuery,
72) -> Result<Response, ServeError> {
73    // A completely unknown run is a 404 (mirrors the SSE path).
74    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    // jsonl (NDJSON): one JSON object per line, then a trailing truncation marker.
110    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
126/// The live SSE stream (unchanged behavior).
127async 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            // No buffer: 404 for an unknown run, or an immediate `end` for a known
135            // run whose logs have already been dropped after the drain window.
136            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            // A dropped sender's receiver is never polled (ended == true).
146            (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
158/// Map an internal [`LogEvent`] to an SSE wire event.
159fn 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}