| 125 | 125 | | pub workspace: String, |
| 126 | 126 | | pub bucket: String, |
| 127 | 127 | | pub cost: i64, |
| 128 | + | /// What the workspace paid in cash. |
| 128 | 129 | | pub revenue: i64, |
| 130 | + | /// What its usage was priced at, whoever paid for it. |
| 131 | + | pub value: i64, |
| 129 | 132 | | } |
| 130 | 133 | | |
| 131 | 134 | | fn micros(dollars: f64) -> i64 { |
| ⋯ |
| 201 | 204 | | let mut value_by: BTreeMap<(String, String), Vec<(String, f64)>> = BTreeMap::new(); |
| 202 | 205 | | let mut cost_by: BTreeMap<(String, String), Vec<(String, f64)>> = BTreeMap::new(); |
| 203 | 206 | | let mut revenue: BTreeMap<(String, String, String), i64> = BTreeMap::new(); |
| 207 | + | let mut valued: BTreeMap<(String, String, String), i64> = BTreeMap::new(); |
| 204 | 208 | | let mut active: BTreeMap<String, Vec<(String, f64)>> = BTreeMap::new(); |
| 205 | 209 | | for u in usage { |
| 206 | 210 | | let bucket = bucket_of(&u.key); |
| ⋯ |
| 211 | 215 | | row.cash_micros += u.cash; |
| 212 | 216 | | value_by.entry(key.clone()).or_default().push((u.workspace.clone(), u.value as f64)); |
| 213 | 217 | | cost_by.entry(key).or_default().push((u.workspace.clone(), u.cost as f64)); |
| 214 | | − | *revenue.entry((u.day.clone(), u.workspace.clone(), bucket)).or_default() += u.cash; |
| 218 | + | *revenue.entry((u.day.clone(), u.workspace.clone(), bucket.clone())).or_default() += u.cash; |
| 219 | + | *valued.entry((u.day.clone(), u.workspace.clone(), bucket)).or_default() += u.value; |
| 215 | 220 | | active.entry(u.day.clone()).or_default().push((u.workspace.clone(), u.value.max(u.cost) as f64)); |
| 216 | 221 | | } |
| 217 | 222 | | // Each bucket's cost shared out: by Cloudflare's own count per |
| ⋯ |
| 238 | 243 | | .map(|(day, workspace, bucket)| WorkspaceDay { |
| 239 | 244 | | cost: shares.get(&(day.clone(), workspace.clone(), bucket.clone())).copied().unwrap_or(0), |
| 240 | 245 | | revenue: revenue.get(&(day.clone(), workspace.clone(), bucket.clone())).copied().unwrap_or(0), |
| 246 | + | value: valued.get(&(day.clone(), workspace.clone(), bucket.clone())).copied().unwrap_or(0), |
| 241 | 247 | | day, |
| 242 | 248 | | workspace, |
| 243 | 249 | | bucket, |
| ⋯ |
| 399 | 405 | | /// Workspaces that cost g1t more than `factor` times what they paid, with |
| 400 | 406 | | /// at least `floor_micros` of cost: each (workspace, cost, revenue), the |
| 401 | 407 | | /// biggest gap first. |
| 408 | + | /// What the overall alert says: the money as money, and a percentage only |
| 409 | + | /// while there is enough coming in for one to mean something (a few cents |
| 410 | + | /// against dollars of cost reads as -8000%). |
| 411 | + | pub(crate) fn overall_detail(took: i64, spent: i64, days: usize, floor: f64, worst: f64) -> String { |
| 412 | + | if took < 1_000_000 * days as i64 { |
| 413 | + | return format!( |
| 414 | + | "All of g1t, comped workspaces left out: took in {} against {} of Cloudflare's bill over {days} days.", |
| 415 | + | dollars(took), |
| 416 | + | dollars(spent) |
| 417 | + | ); |
| 418 | + | } |
| 419 | + | format!("All of g1t, comped workspaces left out: money in against Cloudflare's bill under {floor:.0}% for {days} days running, as low as {worst:.1}%.") |
| 420 | + | } |
| 421 | + | |
| 402 | 422 | | pub(crate) fn anomalies(rows: &[(String, i64, i64)], factor: f64, floor_micros: i64) -> Vec<(String, i64, i64)> { |
| 403 | 423 | | let mut out: Vec<(String, i64, i64)> = rows |
| 404 | 424 | | .iter() |
| ⋯ |
| 770 | 790 | | for w in chunk { |
| 771 | 791 | | statements.push( |
| 772 | 792 | | self.db |
| 773 | | − | .prepare("INSERT OR REPLACE INTO workspace_costs (day, workspace, bucket, cost_micros, revenue_micros) VALUES (?, ?, ?, ?, ?)") |
| 793 | + | .prepare("INSERT OR REPLACE INTO workspace_costs (day, workspace, bucket, cost_micros, revenue_micros, value_micros) VALUES (?, ?, ?, ?, ?, ?)") |
| 774 | 794 | | .bind(&[ |
| 775 | 795 | | w.day.as_str().into(), |
| 776 | 796 | | w.workspace.as_str().into(), |
| 777 | 797 | | w.bucket.as_str().into(), |
| 778 | 798 | | (w.cost as f64).into(), |
| 779 | 799 | | (w.revenue as f64).into(), |
| 800 | + | (w.value as f64).into(), |
| 780 | 801 | | ])?, |
| 781 | 802 | | ); |
| 782 | 803 | | } |
| ⋯ |
| 967 | 988 | | )); |
| 968 | 989 | | } |
| 969 | 990 | | } |
| 991 | + | // Comped workspaces' share is a budget g1t chose to spend, watched |
| 992 | + | // on its own (budget.rs): not part of whether what is sold pays. |
| 993 | + | for (day, cost) in self.comped_costs(&since, until).await? { |
| 994 | + | if let Some(overall) = all.get_mut(&day) { |
| 995 | + | overall.1 = (overall.1 - cost).max(0); |
| 996 | + | } |
| 997 | + | } |
| 970 | 998 | | let series: Vec<(String, i64, i64)> = all.into_iter().map(|(day, (revenue, cost))| (day, revenue, cost)).collect(); |
| 971 | 999 | | if let Some((from, worst)) = breach(&series, floor, n, settings.min_daily_cost_micros) { |
| 972 | | − | conditions.push(( |
| 973 | | − | "overall".into(), |
| 974 | | − | "g1t".into(), |
| 975 | | − | format!("All of g1t: money in against Cloudflare's bill under {floor:.0}% for {n} days running, as low as {worst:.1}%."), |
| 976 | | − | from, |
| 977 | | − | )); |
| 1000 | + | let tail = &series[series.len().saturating_sub(n)..]; |
| 1001 | + | let (took, spent) = tail.iter().fold((0i64, 0i64), |(r, c), (_, revenue, cost)| (r + revenue, c + cost)); |
| 1002 | + | conditions.push(("overall".into(), "g1t".into(), overall_detail(took, spent, n, floor, worst), from)); |
| 978 | 1003 | | } |
| 979 | 1004 | | for (d, detail) in drift { |
| 980 | 1005 | | let kind = if d.kind == DriftKind::Leak { "leak" } else { "drift" }; |
| ⋯ |
| 985 | 1010 | | conditions.push(( |
| 986 | 1011 | | "workspace".into(), |
| 987 | 1012 | | workspace.clone(), |
| 988 | | − | format!("{workspace} cost g1t {} on Cloudflare over {ANOMALY_DAYS} days and paid {}.", dollars(cost), dollars(revenue)), |
| 1013 | + | format!( |
| 1014 | + | "{workspace} cost g1t {} on Cloudflare over {ANOMALY_DAYS} days, and its usage was priced at {}: its prices are below cost.", |
| 1015 | + | dollars(cost), |
| 1016 | + | dollars(revenue) |
| 1017 | + | ), |
| 989 | 1018 | | day_before(until, ANOMALY_DAYS - 1), |
| 990 | 1019 | | )); |
| 991 | 1020 | | } |
| ⋯ |
| 1027 | 1056 | | .run() |
| 1028 | 1057 | | .await?; |
| 1029 | 1058 | | kept.insert(id.clone()); |
| 1030 | | − | to_email.push(detail.clone()); |
| 1059 | + | // A workspace's is for Reach out, not the inbox. |
| 1060 | + | if kind != "workspace" { |
| 1061 | + | to_email.push(detail.clone()); |
| 1062 | + | } |
| 1031 | 1063 | | kept.insert(format!("email:{id}")); |
| 1032 | 1064 | | } |
| 1033 | 1065 | | } |
| ⋯ |
| 1061 | 1093 | | } |
| 1062 | 1094 | | |
| 1063 | 1095 | | /// Workspaces costing g1t more than they pay over 30 days, not g1t's own. |
| 1096 | + | /// Each day's cost shared out to comped workspaces. |
| 1097 | + | async fn comped_costs(&self, since: &str, until: &str) -> Result<Vec<(String, i64)>> { |
| 1098 | + | #[derive(Deserialize)] |
| 1099 | + | struct Row { |
| 1100 | + | day: String, |
| 1101 | + | cost: Option<i64>, |
| 1102 | + | } |
| 1103 | + | Ok(self |
| 1104 | + | .db |
| 1105 | + | .prepare(format!( |
| 1106 | + | "SELECT day, SUM(cost_micros) AS cost FROM workspace_costs |
| 1107 | + | WHERE day >= ?1 AND day <= ?2 AND workspace IN ({}) GROUP BY day", |
| 1108 | + | crate::sales::INTERNAL_SQL |
| 1109 | + | )) |
| 1110 | + | .bind(&[since.into(), until.into()])? |
| 1111 | + | .all() |
| 1112 | + | .await? |
| 1113 | + | .results::<Row>()? |
| 1114 | + | .into_iter() |
| 1115 | + | .map(|r| (r.day, r.cost.unwrap_or(0))) |
| 1116 | + | .collect()) |
| 1117 | + | } |
| 1118 | + | |
| 1064 | 1119 | | async fn workspace_anomalies(&self, until: &str, settings: &CostSettings) -> Result<Vec<(String, i64, i64)>> { |
| 1065 | 1120 | | #[derive(Deserialize)] |
| 1066 | 1121 | | struct Row { |
| ⋯ |
| 1071 | 1126 | | let rows = self |
| 1072 | 1127 | | .db |
| 1073 | 1128 | | .prepare(format!( |
| 1074 | | − | "SELECT workspace, SUM(cost_micros) AS cost, SUM(revenue_micros) AS revenue FROM workspace_costs |
| 1129 | + | // Against what its usage was priced at, not the cash it |
| 1130 | + | // paid: a trial or a gift paying for usage is not a price |
| 1131 | + | // below cost. |
| 1132 | + | "SELECT workspace, SUM(cost_micros) AS cost, SUM(value_micros) AS revenue FROM workspace_costs |
| 1075 | 1133 | | WHERE day >= ?1 AND day <= ?2 AND workspace NOT IN ({}) GROUP BY workspace", |
| 1076 | 1134 | | crate::sales::INTERNAL_SQL |
| 1077 | 1135 | | )) |
| ⋯ |
| 1410 | 1468 | | mod tests { |
| 1411 | 1469 | | use super::*; |
| 1412 | 1470 | | |
| 1471 | + | #[test] |
| 1472 | + | fn the_overall_alert_says_dollars_while_little_comes_in() { |
| 1473 | + | let small = overall_detail(90_000, 7_500_000, 3, 10.0, -8239.7); |
| 1474 | + | assert!(small.contains("took in $0.09 against $7.50"), "{small}"); |
| 1475 | + | assert!(!small.contains('%'), "{small}"); |
| 1476 | + | let real = overall_detail(30_000_000, 40_000_000, 3, 10.0, -33.3); |
| 1477 | + | assert!(real.contains("as low as -33.3%"), "{real}"); |
| 1478 | + | } |
| 1479 | + | |
| 1413 | 1480 | | fn rule(product: &str, meter: &str, bucket: &str, own: Option<&str>) -> Rule { |
| 1414 | 1481 | | Rule { product: product.into(), meter: meter.into(), bucket: bucket.into(), price_meter: None, own_meter: own.map(Into::into), drift_percent: 10.0 } |
| 1415 | 1482 | | } |