use super::*;
pub(super) fn cost_explorer_client(base: &SdkConfig) -> CostExplorerClient {
let region = base.region().map(|r| r.to_string()).unwrap_or_default();
let cfg = base
.to_builder()
.region(Region::new(super::global_service_region(®ion)))
.build();
CostExplorerClient::new(&cfg)
}
#[derive(Clone, Debug, Default)]
pub struct EnvCosts {
pub rows: Vec<EnvCost>,
pub truncated: bool,
}
#[derive(Clone, Debug)]
pub struct EnvCost {
pub env_name: String,
pub cost_usd: f64,
}
impl AwsClient {
pub async fn fetch_env_costs(&self) -> Result<EnvCosts> {
use aws_sdk_costexplorer::types::{DateInterval, GroupDefinition, GroupDefinitionType};
let now = chrono::Utc::now().date_naive();
let start = (now - chrono::Duration::days(30))
.format("%Y-%m-%d")
.to_string();
let end = now.format("%Y-%m-%d").to_string();
let time_period = DateInterval::builder()
.start(start)
.end(end)
.build()
.wrap_err("Cost Explorer DateInterval missing field")?;
let group_by = GroupDefinition::builder()
.r#type(GroupDefinitionType::Tag)
.key("elasticbeanstalk:environment-name")
.build();
let mut totals: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
let mut next_page: Option<String> = None;
const MAX_COST_PAGES: usize = 20;
for _page in 0..MAX_COST_PAGES {
let mut req = self
.cost()
.get_cost_and_usage()
.time_period(time_period.clone())
.granularity(aws_sdk_costexplorer::types::Granularity::Monthly)
.metrics("UnblendedCost")
.group_by(group_by.clone());
if let Some(t) = next_page.take() {
req = req.next_page_token(t);
}
let resp = req.send().await.wrap_err("GetCostAndUsage failed")?;
for period in resp.results_by_time.unwrap_or_default() {
for group in period.groups.unwrap_or_default() {
let raw_key = match group.keys.as_ref().and_then(|k| k.first()) {
Some(k) => k.clone(),
None => continue,
};
let env_name = match raw_key.split_once('$') {
Some((_, v)) if !v.is_empty() => v.to_string(),
_ => continue,
};
let amount: f64 = group
.metrics
.as_ref()
.and_then(|m| m.get("UnblendedCost"))
.and_then(|m| m.amount.as_deref())
.and_then(|s| s.parse().ok())
.filter(|a: &f64| a.is_finite())
.unwrap_or(0.0);
*totals.entry(env_name).or_insert(0.0) += amount;
}
}
match resp.next_page_token {
Some(t) if !t.is_empty() => next_page = Some(t),
_ => {
next_page = None;
break;
}
}
}
let truncated = next_page.is_some();
if truncated {
tracing::warn!(
target: "ebman::aws",
max_pages = MAX_COST_PAGES,
envs = totals.len(),
"Cost Explorer page cap reached with more pages available — \
costs are incomplete and will not be cached"
);
}
let mut out: Vec<EnvCost> = totals
.into_iter()
.map(|(env_name, cost_usd)| EnvCost { env_name, cost_usd })
.collect();
out.sort_by(|a, b| {
b.cost_usd
.partial_cmp(&a.cost_usd)
.unwrap_or(std::cmp::Ordering::Equal)
});
Ok(EnvCosts {
rows: out,
truncated,
})
}
}