| 1 | //! `g1t logs`: a project's log lines, or its errors grouped. |
| 2 | |
| 3 | use serde_json::Value; |
| 4 | use std::time::Duration; |
| 5 | |
| 6 | use crate::cli::LogsArgs; |
| 7 | use crate::client::Client; |
| 8 | use crate::context::Ctx; |
| 9 | use crate::error::{Error, Result}; |
| 10 | use crate::output::{Cell, Output, s}; |
| 11 | use crate::repo::Repo; |
| 12 | |
| 13 | /// `15m`, `2h`, `1d`, `1w` as Unix milliseconds that long ago; anything |
| 14 | /// else (an RFC 3339 time) as it is. |
| 15 | pub fn since(text: &str, now_ms: i64) -> String { |
| 16 | let text = text.trim(); |
| 17 | let (number, unit) = text.split_at( |
| 18 | text.find(|c: char| !c.is_ascii_digit()) |
| 19 | .unwrap_or(text.len()), |
| 20 | ); |
| 21 | let secs = match unit { |
| 22 | "s" => 1, |
| 23 | "m" => 60, |
| 24 | "h" => 3600, |
| 25 | "d" => 86_400, |
| 26 | "w" => 604_800, |
| 27 | _ => return text.to_owned(), |
| 28 | }; |
| 29 | match number.parse::<i64>() { |
| 30 | Ok(n) => (now_ms - n * secs * 1000).to_string(), |
| 31 | Err(_) => text.to_owned(), |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | /// `2026-10-12 09:01:02` (UTC) for microseconds since 1970. |
| 36 | pub fn clock(time_us: i64) -> String { |
| 37 | let secs = time_us.div_euclid(1_000_000); |
| 38 | let days = secs.div_euclid(86_400); |
| 39 | let rem = secs.rem_euclid(86_400); |
| 40 | // Civil from days (Howard Hinnant's algorithm). |
| 41 | let z = days + 719_468; |
| 42 | let era = z.div_euclid(146_097); |
| 43 | let doe = z - era * 146_097; |
| 44 | let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; |
| 45 | let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); |
| 46 | let mp = (5 * doy + 2) / 153; |
| 47 | let d = doy - (153 * mp + 2) / 5 + 1; |
| 48 | let m = if mp < 10 { mp + 3 } else { mp - 9 }; |
| 49 | let y = yoe + era * 400 + i64::from(m <= 2); |
| 50 | format!( |
| 51 | "{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}", |
| 52 | rem / 3600, |
| 53 | (rem % 3600) / 60, |
| 54 | rem % 60 |
| 55 | ) |
| 56 | } |
| 57 | |
| 58 | /// The workspace and project: --project (SLUG or WORKSPACE/SLUG), or the |
| 59 | /// current repository's project. |
| 60 | fn project(ctx: &Ctx, args: &LogsArgs) -> Result<(String, String, Client)> { |
| 61 | if let Some(p) = &args.project |
| 62 | && let Some((w, slug)) = p.split_once('/') |
| 63 | { |
| 64 | return Ok((w.to_owned(), slug.to_owned(), ctx.signed_in_client()?)); |
| 65 | } |
| 66 | let (repo, client) = ctx.repo_client()?; |
| 67 | if let Some(slug) = &args.project { |
| 68 | return Ok((repo.owner.clone(), slug.clone(), client)); |
| 69 | } |
| 70 | let (workspace, slug) = project_of(&client, &repo)?; |
| 71 | Ok((workspace, slug, client)) |
| 72 | } |
| 73 | |
| 74 | /// The project whose repository this is. A repository moved to another |
| 75 | /// workspace answers to its old name, so the projects are looked up where |
| 76 | /// it is now. |
| 77 | fn project_of(client: &Client, repo: &Repo) -> Result<(String, String)> { |
| 78 | let found = client.op("get_repo").repo(repo).send()?; |
| 79 | let here = Repo { |
| 80 | host: repo.host.clone(), |
| 81 | owner: found["namespace"] |
| 82 | .as_str() |
| 83 | .unwrap_or(&repo.owner) |
| 84 | .to_owned(), |
| 85 | name: found["name"].as_str().unwrap_or(&repo.name).to_owned(), |
| 86 | }; |
| 87 | let projects = super::items( |
| 88 | client |
| 89 | .op("list_projects") |
| 90 | .arg("workspace", &here.owner) |
| 91 | .send()?, |
| 92 | "projects", |
| 93 | ); |
| 94 | let full = here.to_string(); |
| 95 | let mine: Vec<&Value> = projects |
| 96 | .iter() |
| 97 | .filter(|p| s(p, "repository").eq_ignore_ascii_case(&full)) |
| 98 | .collect(); |
| 99 | let pick = mine.iter().find(|p| p["primary"] == true).or(mine.first()); |
| 100 | match pick { |
| 101 | Some(p) => Ok((here.owner.clone(), s(p, "slug").to_owned())), |
| 102 | None => Err(Error::failed(format!( |
| 103 | "{here} has no project. Name one with --project WORKSPACE/SLUG." |
| 104 | ))), |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | fn print_lines(out: &Output, lines: &[Value]) -> Result<()> { |
| 109 | if !out.tty { |
| 110 | for l in lines { |
| 111 | let attrs = serde_json::to_string(&l["attributes"]).unwrap_or_default(); |
| 112 | println!( |
| 113 | "{}\t{}\t{}\t{}\t{}", |
| 114 | clock(l["time_us"].as_i64().unwrap_or(0)), |
| 115 | s(l, "severity"), |
| 116 | s(l, "service"), |
| 117 | s(l, "body").replace('\n', "\\n"), |
| 118 | attrs |
| 119 | ); |
| 120 | } |
| 121 | return Ok(()); |
| 122 | } |
| 123 | for l in lines { |
| 124 | let sev = s(l, "severity"); |
| 125 | let painted = match sev { |
| 126 | "error" | "fatal" => out.red(sev), |
| 127 | "warn" => out.yellow(sev), |
| 128 | "debug" | "trace" => out.dim(sev), |
| 129 | _ => out.cyan(sev), |
| 130 | }; |
| 131 | let pad = " ".repeat(5usize.saturating_sub(sev.len())); |
| 132 | println!( |
| 133 | "{} {painted}{pad} {} {}", |
| 134 | out.dim(&clock(l["time_us"].as_i64().unwrap_or(0))), |
| 135 | out.dim(s(l, "service")), |
| 136 | s(l, "body") |
| 137 | ); |
| 138 | } |
| 139 | Ok(()) |
| 140 | } |
| 141 | |
| 142 | pub fn run(ctx: &Ctx, args: LogsArgs) -> Result<()> { |
| 143 | let (workspace, slug, client) = project(ctx, &args)?; |
| 144 | let now_ms = crate::output::now() * 1000; |
| 145 | let from = args.since.as_deref().map(|t| since(t, now_ms)); |
| 146 | if args.errors { |
| 147 | let found = client |
| 148 | .op("list_errors") |
| 149 | .arg("workspace", &workspace) |
| 150 | .arg("project", &slug) |
| 151 | .query_opt("state", args.state.clone()) |
| 152 | .query_opt("environment", args.env.clone()) |
| 153 | .query_opt("service", args.service.clone()) |
| 154 | .query_opt("from", from) |
| 155 | .send()?; |
| 156 | let mut errors = super::items(found, "errors"); |
| 157 | errors.truncate(args.limit); |
| 158 | if ctx.out.structured() { |
| 159 | return ctx.out.emit(&Value::Array(errors)); |
| 160 | } |
| 161 | if errors.is_empty() { |
| 162 | eprintln!("No errors in {workspace}/{slug}."); |
| 163 | return Ok(()); |
| 164 | } |
| 165 | let out = &ctx.out; |
| 166 | let rows: Vec<Vec<Cell>> = errors |
| 167 | .iter() |
| 168 | .map(|e| { |
| 169 | vec![ |
| 170 | Cell::new(e["count"].to_string()), |
| 171 | Cell::styled( |
| 172 | format!("{}: {}", s(e, "name"), s(e, "message")), |
| 173 | format!("{}: {}", out.red(s(e, "name")), s(e, "message")), |
| 174 | ), |
| 175 | Cell::new(format!("{} {}", s(e, "service"), s(e, "environment"))), |
| 176 | Cell::styled(s(e, "state"), out.state(s(e, "state"))), |
| 177 | Cell::new(clock(e["last_us"].as_i64().unwrap_or(0))), |
| 178 | Cell::styled(s(e, "fingerprint"), out.dim(s(e, "fingerprint"))), |
| 179 | ] |
| 180 | }) |
| 181 | .collect(); |
| 182 | return out.table( |
| 183 | &[ |
| 184 | "count", |
| 185 | "error", |
| 186 | "where", |
| 187 | "state", |
| 188 | "last seen", |
| 189 | "fingerprint", |
| 190 | ], |
| 191 | &rows, |
| 192 | ); |
| 193 | } |
| 194 | let fetch = |from: Option<String>, limit: usize| -> Result<Vec<Value>> { |
| 195 | let found = client |
| 196 | .op("list_logs") |
| 197 | .arg("workspace", &workspace) |
| 198 | .arg("project", &slug) |
| 199 | .query_opt("q", args.query.clone()) |
| 200 | .query_opt("level", args.level.clone()) |
| 201 | .query_opt("environment", args.env.clone()) |
| 202 | .query_opt("service", args.service.clone()) |
| 203 | .query_opt("from", from) |
| 204 | .query("limit", limit.clamp(1, 1000)) |
| 205 | .send()?; |
| 206 | Ok(super::items(found, "items")) |
| 207 | }; |
| 208 | let mut lines = fetch(from, args.limit)?; |
| 209 | lines.truncate(args.limit); |
| 210 | // Newest first from the API; oldest first reads like a log. |
| 211 | lines.reverse(); |
| 212 | if ctx.out.structured() && !args.follow { |
| 213 | return ctx.out.emit(&Value::Array(lines)); |
| 214 | } |
| 215 | let emit = |batch: &[Value]| -> Result<()> { |
| 216 | if ctx.out.structured() { |
| 217 | for l in batch { |
| 218 | ctx.out.emit(l)?; |
| 219 | } |
| 220 | Ok(()) |
| 221 | } else { |
| 222 | print_lines(&ctx.out, batch) |
| 223 | } |
| 224 | }; |
| 225 | emit(&lines)?; |
| 226 | if !args.follow { |
| 227 | if lines.is_empty() && !ctx.out.structured() { |
| 228 | eprintln!( |
| 229 | "No log lines in {workspace}/{slug}{}.", |
| 230 | args.since |
| 231 | .as_deref() |
| 232 | .map(|s| format!(" in the last {s}")) |
| 233 | .unwrap_or_default() |
| 234 | ); |
| 235 | } |
| 236 | return Ok(()); |
| 237 | } |
| 238 | let mut last_us = lines |
| 239 | .iter() |
| 240 | .filter_map(|l| l["time_us"].as_i64()) |
| 241 | .max() |
| 242 | .unwrap_or(now_ms * 1000); |
| 243 | let mut seen: std::collections::HashSet<String> = |
| 244 | lines.iter().map(|l| s(l, "id").to_owned()).collect(); |
| 245 | loop { |
| 246 | std::thread::sleep(Duration::from_secs(2)); |
| 247 | let mut batch = fetch(Some((last_us / 1000).to_string()), 200)?; |
| 248 | batch.retain(|l| seen.insert(s(l, "id").to_owned())); |
| 249 | batch.reverse(); |
| 250 | if let Some(max) = batch.iter().filter_map(|l| l["time_us"].as_i64()).max() { |
| 251 | last_us = last_us.max(max); |
| 252 | } |
| 253 | emit(&batch)?; |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | #[cfg(test)] |
| 258 | mod tests { |
| 259 | use super::*; |
| 260 | |
| 261 | #[test] |
| 262 | fn spans_become_times() { |
| 263 | assert_eq!( |
| 264 | since("15m", 1_000_000_000), |
| 265 | (1_000_000_000 - 900_000).to_string() |
| 266 | ); |
| 267 | assert_eq!( |
| 268 | since("2h", 10_000_000_000), |
| 269 | (10_000_000_000i64 - 7_200_000).to_string() |
| 270 | ); |
| 271 | assert_eq!(since("2026-10-12T09:00:00Z", 0), "2026-10-12T09:00:00Z"); |
| 272 | } |
| 273 | |
| 274 | #[test] |
| 275 | fn clocks_read_in_utc() { |
| 276 | assert_eq!(clock(0), "1970-01-01 00:00:00"); |
| 277 | assert_eq!(clock(1_790_877_851_000_000), "2026-10-01 18:04:11"); |
| 278 | } |
| 279 | } |