Skip to main content

life_cli/
logs.rs

1//! Log streaming for deployed agents.
2//!
3//! Queries the Railway deployment logs API and streams them to stdout.
4//! Falls back to querying service health endpoints if the API is unreachable.
5
6use anyhow::{Context, Result};
7use serde::Deserialize;
8use serde_json::{Value, json};
9
10use crate::cli::LogsArgs;
11use crate::deploy::DeploymentState;
12
13const RAILWAY_API_URL: &str = "https://backboard.railway.app/graphql/v2";
14
15/// Fetch deployment logs from Railway via GraphQL.
16async fn fetch_railway_logs(token: &str, deployment_id: &str, limit: u32) -> Result<Vec<LogEntry>> {
17    let client = reqwest::Client::new();
18
19    let query = r#"query ($deploymentId: String!, $limit: Int) {
20        deploymentLogs(deploymentId: $deploymentId, limit: $limit) {
21            timestamp
22            message
23            severity
24        }
25    }"#;
26
27    let body = json!({
28        "query": query,
29        "variables": {
30            "deploymentId": deployment_id,
31            "limit": limit,
32        },
33    });
34
35    let resp = client
36        .post(RAILWAY_API_URL)
37        .header("Content-Type", "application/json")
38        .header("Authorization", format!("Bearer {token}"))
39        .json(&body)
40        .send()
41        .await
42        .context("failed to reach Railway API")?;
43
44    if !resp.status().is_success() {
45        let status = resp.status();
46        let text = resp.text().await.unwrap_or_default();
47        anyhow::bail!("Railway API returned HTTP {status}: {text}");
48    }
49
50    let json: Value = resp
51        .json()
52        .await
53        .context("failed to parse Railway response")?;
54
55    if let Some(errors) = json.get("errors") {
56        if let Some(arr) = errors.as_array() {
57            if !arr.is_empty() {
58                let messages: Vec<&str> = arr
59                    .iter()
60                    .filter_map(|e| e.get("message").and_then(Value::as_str))
61                    .collect();
62                anyhow::bail!("Railway GraphQL error: {}", messages.join("; "));
63            }
64        }
65    }
66
67    let logs = json
68        .get("data")
69        .and_then(|d| d.get("deploymentLogs"))
70        .cloned()
71        .unwrap_or(Value::Array(vec![]));
72
73    let entries: Vec<LogEntry> = serde_json::from_value(logs).unwrap_or_default();
74    Ok(entries)
75}
76
77/// Fetch the latest deployment ID for a service from Railway.
78async fn fetch_latest_deployment_id(
79    token: &str,
80    project_id: &str,
81    service_name: &str,
82) -> Result<Option<String>> {
83    let client = reqwest::Client::new();
84
85    #[derive(Deserialize)]
86    struct ProjectData {
87        project: ProjectServices,
88    }
89    #[derive(Deserialize)]
90    struct ProjectServices {
91        services: Edges<ServiceNode>,
92    }
93    #[derive(Deserialize)]
94    struct Edges<T> {
95        edges: Vec<Edge<T>>,
96    }
97    #[derive(Deserialize)]
98    struct Edge<T> {
99        node: T,
100    }
101    #[derive(Deserialize)]
102    struct ServiceNode {
103        name: String,
104        #[serde(rename = "serviceInstances")]
105        service_instances: Edges<InstanceNode>,
106    }
107    #[derive(Deserialize)]
108    struct InstanceNode {
109        #[serde(rename = "latestDeployment")]
110        latest_deployment: Option<DeploymentRef>,
111    }
112    #[derive(Deserialize)]
113    struct DeploymentRef {
114        id: String,
115    }
116
117    let query = r#"query ($projectId: String!) {
118        project(id: $projectId) {
119            services {
120                edges {
121                    node {
122                        name
123                        serviceInstances {
124                            edges {
125                                node {
126                                    latestDeployment { id }
127                                }
128                            }
129                        }
130                    }
131                }
132            }
133        }
134    }"#;
135
136    let body = json!({
137        "query": query,
138        "variables": { "projectId": project_id },
139    });
140
141    let resp = client
142        .post(RAILWAY_API_URL)
143        .header("Content-Type", "application/json")
144        .header("Authorization", format!("Bearer {token}"))
145        .json(&body)
146        .send()
147        .await
148        .context("failed to reach Railway API")?;
149
150    let json: Value = resp.json().await?;
151    let data: ProjectData = serde_json::from_value(json.get("data").cloned().unwrap_or_default())?;
152
153    for edge in data.project.services.edges {
154        if edge.node.name == service_name {
155            return Ok(edge
156                .node
157                .service_instances
158                .edges
159                .first()
160                .and_then(|i| i.node.latest_deployment.as_ref())
161                .map(|d| d.id.clone()));
162        }
163    }
164
165    Ok(None)
166}
167
168#[derive(Debug, Deserialize)]
169struct LogEntry {
170    #[serde(default)]
171    timestamp: Option<String>,
172    #[serde(default)]
173    message: String,
174    #[serde(default)]
175    severity: Option<String>,
176}
177
178pub async fn run(args: LogsArgs) -> Result<()> {
179    let state = DeploymentState::load(&args.agent)
180        .with_context(|| format!("no deployment found for agent '{}'", args.agent))?;
181
182    let token = std::env::var("RAILWAY_API_TOKEN")
183        .context("RAILWAY_API_TOKEN required for log streaming")?;
184
185    // Determine which services to fetch logs for
186    let service_names: Vec<String> = if let Some(ref svc) = args.service {
187        if !state.services.contains_key(svc) {
188            let available: Vec<&str> = state.services.keys().map(String::as_str).collect();
189            anyhow::bail!(
190                "service '{svc}' not found. Available: {}",
191                available.join(", ")
192            );
193        }
194        vec![svc.clone()]
195    } else {
196        state.services.keys().cloned().collect()
197    };
198
199    println!(
200        "Logs for agent '{}' (project: {})",
201        state.agent_name, state.project_name
202    );
203    println!("═══════════════════════════════════════════");
204
205    let mut found_any = false;
206
207    for svc_name in &service_names {
208        // Get the latest deployment ID for this service
209        let deployment_id = fetch_latest_deployment_id(&token, &state.project_id, svc_name).await?;
210
211        let Some(deployment_id) = deployment_id else {
212            println!("\n[{svc_name}] No active deployment found.");
213            continue;
214        };
215
216        match fetch_railway_logs(&token, &deployment_id, args.lines).await {
217            Ok(entries) => {
218                if entries.is_empty() {
219                    println!("\n[{svc_name}] No log entries.");
220                    continue;
221                }
222
223                found_any = true;
224                println!("\n── {svc_name} ({} entries) ──", entries.len());
225
226                for entry in &entries {
227                    let ts = entry.timestamp.as_deref().unwrap_or("                   ");
228                    let severity = entry.severity.as_deref().unwrap_or("INFO");
229                    let msg = &entry.message;
230
231                    // Truncate long timestamps to readable form
232                    let ts_display = if ts.len() > 19 { &ts[..19] } else { ts };
233
234                    println!("{ts_display} [{severity:>5}] {msg}");
235                }
236            }
237            Err(e) => {
238                eprintln!("\n[{svc_name}] Failed to fetch logs: {e}");
239            }
240        }
241    }
242
243    if !found_any && args.service.is_none() {
244        println!();
245        println!("No logs found for any service.");
246        println!(
247            "Services may still be deploying. Run `life status --agent {}` to check.",
248            args.agent
249        );
250    }
251
252    Ok(())
253}