use super::*;
#[derive(Clone, Debug)]
pub struct LogEvent {
pub timestamp_ms: i64,
pub stream: String,
pub message: String,
}
#[derive(Clone, Debug)]
pub struct InsightsRow {
pub fields: Vec<(String, String)>,
}
#[derive(Clone, Debug)]
pub struct InsightsResults {
pub rows: Vec<InsightsRow>,
pub records_scanned: i64,
pub records_matched: i64,
}
pub fn parse_window_ms(input: &str) -> Option<i64> {
let s = input.trim().to_lowercase();
if s.is_empty() {
return None;
}
let unit = s.chars().last()?;
let num: i64 = s[..s.len() - unit.len_utf8()].parse().ok()?;
if num <= 0 {
return None;
}
let ms = match unit {
's' => num.checked_mul(1_000),
'm' => num.checked_mul(60_000),
'h' => num.checked_mul(60 * 60_000),
'd' => num.checked_mul(24 * 60 * 60_000),
_ => return None,
}?;
const MAX_WINDOW_MS: i64 = 100 * 365 * 24 * 60 * 60 * 1_000;
if ms > MAX_WINDOW_MS {
return None;
}
Some(ms)
}
pub fn format_insights_results(
results: &InsightsResults,
query: &str,
log_groups: &[String],
) -> String {
let mut out = String::new();
out.push_str(&format!(
"query: {query}\nlog groups: {}\nmatched: {} / scanned: {}\n",
if log_groups.is_empty() {
"(none)".to_string()
} else {
log_groups.join(", ")
},
results.records_matched,
results.records_scanned,
));
out.push_str(&"─".repeat(60));
out.push('\n');
if results.rows.is_empty() {
out.push_str("(no rows matched the query)\n");
return out;
}
let mut headers: Vec<String> = Vec::new();
for row in &results.rows {
for (k, _) in &row.fields {
if k != "@ptr" && !headers.iter().any(|h| h == k) {
headers.push(k.clone());
}
}
}
const COL_MAX: usize = 60;
let mut widths: Vec<usize> = headers.iter().map(|h| h.chars().count()).collect();
for row in &results.rows {
for (i, h) in headers.iter().enumerate() {
if let Some((_, v)) = row.fields.iter().find(|(k, _)| k == h) {
let cells = v.chars().count().min(COL_MAX);
if cells > widths[i] {
widths[i] = cells;
}
}
}
}
let mut header_line = String::new();
for (i, h) in headers.iter().enumerate() {
if i > 0 {
header_line.push_str(" ");
}
header_line.push_str(&format!("{:<w$}", h, w = widths[i]));
}
out.push_str(&header_line);
out.push('\n');
let mut sep_line = String::new();
for (i, w) in widths.iter().enumerate() {
if i > 0 {
sep_line.push_str(" ");
}
sep_line.push_str(&"─".repeat(*w));
}
out.push_str(&sep_line);
out.push('\n');
for row in &results.rows {
let mut line = String::new();
for (i, h) in headers.iter().enumerate() {
if i > 0 {
line.push_str(" ");
}
let raw = row
.fields
.iter()
.find(|(k, _)| k == h)
.map(|(_, v)| v.as_str())
.unwrap_or("");
let trimmed: String = if raw.chars().count() > COL_MAX {
let mut s: String = raw.chars().take(COL_MAX.saturating_sub(1)).collect();
s.push('…');
s
} else {
raw.to_string()
};
line.push_str(&format!("{:<w$}", trimmed, w = widths[i]));
}
out.push_str(&line);
out.push('\n');
}
out
}
impl AwsClient {
pub async fn discover_env_log_groups(&self, env_name: &str) -> Result<Vec<String>> {
let prefix = format!("/aws/elasticbeanstalk/{env_name}/");
let (this, pfx) = (self, prefix.as_str());
let raw = super::paginate("DescribeLogGroups", move |token| async move {
let mut req = this
.cw_logs
.describe_log_groups()
.log_group_name_prefix(pfx);
if let Some(t) = token {
req = req.next_token(t);
}
let resp = req.send().await.wrap_err("DescribeLogGroups failed")?;
Ok((resp.log_groups.unwrap_or_default(), resp.next_token))
})
.await?
.complete("DescribeLogGroups")?;
let mut out: Vec<String> = raw.into_iter().filter_map(|g| g.log_group_name).collect();
out.sort();
Ok(out)
}
pub async fn fetch_recent_log_events(
&self,
log_group: &str,
since_ms: i64,
limit: i32,
skip_at_since: &std::collections::HashSet<String>,
) -> Result<(Vec<LogEvent>, i64, std::collections::HashSet<String>)> {
const MAX_PAGES_PER_POLL: usize = 5;
let mut out: Vec<LogEvent> = Vec::new();
let mut max_ts = since_ms;
let mut next_token: Option<String> = None;
let mut truncated = false;
let mut boundary_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
for _page in 0..MAX_PAGES_PER_POLL {
let mut req = self
.cw_logs
.filter_log_events()
.log_group_name(log_group)
.start_time(since_ms)
.limit(limit);
if let Some(t) = next_token.take() {
req = req.next_token(t);
}
let resp = req.send().await.wrap_err("FilterLogEvents failed")?;
for e in resp.events.unwrap_or_default() {
let ts = e.timestamp.unwrap_or(since_ms);
let id = e.event_id.unwrap_or_default();
if ts == since_ms && !id.is_empty() && skip_at_since.contains(&id) {
continue;
}
if ts > max_ts {
max_ts = ts;
boundary_ids.clear();
}
if ts == max_ts && !id.is_empty() {
boundary_ids.insert(id);
}
out.push(LogEvent {
timestamp_ms: ts,
stream: e.log_stream_name.unwrap_or_default(),
message: e.message.unwrap_or_default(),
});
}
match resp.next_token {
Some(t) if !t.is_empty() => {
next_token = Some(t);
truncated = true;
}
_ => {
truncated = false;
break;
}
}
}
let next_since = if max_ts > since_ms {
if truncated {
max_ts
} else {
max_ts + 1
}
} else {
since_ms
};
let carry = if next_since == since_ms {
let mut c = skip_at_since.clone();
c.extend(boundary_ids);
c
} else if truncated {
boundary_ids
} else {
std::collections::HashSet::new()
};
Ok((out, next_since, carry))
}
pub async fn run_insights_query(
&self,
log_groups: &[String],
start_ms: i64,
end_ms: i64,
query: &str,
) -> Result<InsightsResults> {
use aws_sdk_cloudwatchlogs::types::QueryStatus;
let start_s = start_ms / 1000;
let end_s = end_ms / 1000;
let mut req = self
.cw_logs
.start_query()
.start_time(start_s)
.end_time(end_s)
.query_string(query);
for g in log_groups {
req = req.log_group_names(g);
}
let start_resp = req.send().await.wrap_err("StartQuery failed")?;
let query_id = start_resp
.query_id
.ok_or_else(|| eyre!("StartQuery returned no query_id"))?;
loop {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let resp = self
.cw_logs
.get_query_results()
.query_id(&query_id)
.send()
.await
.wrap_err("GetQueryResults failed")?;
let status = resp.status.clone();
let scanned = resp
.statistics
.as_ref()
.map(|s| s.records_scanned as i64)
.unwrap_or(0);
let matched = resp
.statistics
.as_ref()
.map(|s| s.records_matched as i64)
.unwrap_or(0);
match status {
Some(QueryStatus::Scheduled) | Some(QueryStatus::Running) => continue,
Some(QueryStatus::Complete) => {
let rows: Vec<InsightsRow> = resp
.results
.unwrap_or_default()
.into_iter()
.map(|fields| InsightsRow {
fields: fields
.into_iter()
.map(|f| (f.field.unwrap_or_default(), f.value.unwrap_or_default()))
.collect(),
})
.collect();
return Ok(InsightsResults {
rows,
records_scanned: scanned,
records_matched: matched,
});
}
Some(QueryStatus::Failed) => {
return Err(eyre!("Insights query failed"));
}
Some(QueryStatus::Cancelled) => {
return Err(eyre!("Insights query was cancelled"));
}
Some(QueryStatus::Timeout) => {
return Err(eyre!("Insights query timed out (server-side 15min cap)"));
}
Some(other) => {
return Err(eyre!(
"unexpected Insights query status: {}",
other.as_str()
));
}
None => {
return Err(eyre!("Insights query returned no status"));
}
}
}
}
}