The keeper reads Cloudflare as it really answers
Probed with the keeper's token: the usage API is /billable-usage, with daily rows whose PricingQuantity still includes the free tier, so a rate is only read off days that were charged. AI Gateway ignores bracketed filters and returned every log; the filter now goes as encoded JSON. A sandbox second is now its memory and disk for the whole second plus the CPU sandboxes really use, from Containers' usage analytics (byte-seconds and CPU seconds), at billed rates or Cloudflare's published ones while the included amount covers them. The costs are first checked at once, then daily.
2 files+159−400/2 viewed
| 39 | 39 | const GIVE_UP_AFTER_MS: u64 = 3 * 60 * 60 * 1000; | |
| 40 | 40 | /// A run never finished after this died without reporting. | |
| 41 | 41 | const ABANDONED_AFTER_MS: u64 = 3 * 60 * 60 * 1000; | |
| 42 | − | /// Too little spend to measure a cost from. | |
| 43 | − | const MIN_MEASURED_USD: f64 = 5.0; | |
| 44 | 42 | /// Smaller moves are noise. | |
| 45 | 43 | const MIN_CHANGE: f64 = 0.02; | |
| 46 | 44 | /// A measurement outside this factor of the current cost is suspect. | |
| 96 | 94 | let mut cost = 0.0; | |
| 97 | 95 | let mut count = 0; | |
| 98 | 96 | for page in 1..=40 { | |
| 97 | + | // The filter goes as URL-encoded JSON; the bracket form is | |
| 98 | + | // ignored, and would sum every log there is. Session ids are | |
| 99 | + | // [a-z0-9_], which need no escaping inside it. | |
| 100 | + | let filter = format!( | |
| 101 | + | "%5B%7B%22key%22%3A%22metadata.value%22%2C%22operator%22%3A%22eq%22%2C%22value%22%3A%5B%22{session}%22%5D%7D%5D" | |
| 102 | + | ); | |
| 99 | 103 | let url = self.api(&format!( | |
| 100 | − | "/ai-gateway/gateways/{}/logs?per_page=50&page={page}\ | |
| 101 | − | &filters[0][key]=metadata.value&filters[0][operator]=eq&filters[0][value][0]={session}", | |
| 104 | + | "/ai-gateway/gateways/{}/logs?per_page=50&page={page}&filters={filter}", | |
| 102 | 105 | self.gateway | |
| 103 | 106 | )); | |
| 104 | 107 | let body = self.send(Method::Get, &url, None).await?; | |
| 114 | 117 | Ok((cost, count)) | |
| 115 | 118 | } | |
| 116 | 119 | ||
| 117 | − | /// The account's billable usage this month, as Cloudflare reports it. | |
| 120 | + | /// The account's billable usage, one row per service per day, as | |
| 121 | + | /// Cloudflare reports it. | |
| 118 | 122 | async fn billable_usage(&self, from: &str, to: &str) -> Result<Vec<UsageRow>> { | |
| 119 | 123 | let body = self | |
| 120 | − | .send(Method::Get, &self.api(&format!("/billing/usage/paygo?from={from}&to={to}")), None) | |
| 124 | + | .send(Method::Get, &self.api(&format!("/billable-usage?from={from}&to={to}")), None) | |
| 121 | 125 | .await?; | |
| 122 | 126 | let rows = body["result"].as_array().cloned().unwrap_or_default(); | |
| 123 | 127 | Ok(rows.iter().filter_map(UsageRow::from_value).collect()) | |
| 124 | 128 | } | |
| 125 | 129 | ||
| 126 | − | /// Seconds g1t's containers ran since `since` (RFC 3339), across the | |
| 127 | − | /// account. | |
| 128 | − | async fn container_seconds(&self, since: &str, until: &str) -> Result<f64> { | |
| 129 | − | let query = "query ($account: String!, $since: Time!, $until: Time!) { | |
| 130 | + | /// What g1t's containers used from `since` to `until` (dates), as | |
| 131 | + | /// Cloudflare bills it: memory in byte-seconds, and CPU seconds. | |
| 132 | + | async fn container_usage(&self, since: &str, until: &str) -> Result<ContainerUsage> { | |
| 133 | + | let query = "query ($account: String!, $since: Date!, $until: Date!) { | |
| 130 | 134 | viewer { accounts(filter: { accountTag: $account }) { | |
| 131 | − | containersMetricsAdaptiveGroups(limit: 10000, filter: { datetime_geq: $since, datetime_leq: $until }) { | |
| 132 | − | sum { containerUptime } | |
| 135 | + | containersUsageAdaptiveGroups(limit: 1000, filter: { date_geq: $since, date_leq: $until }) { | |
| 136 | + | sum { cpuTimeSec allocatedMemory } | |
| 133 | 137 | } | |
| 134 | 138 | } } | |
| 135 | 139 | }"; | |
| 140 | 144 | Some(json!({ "query": query, "variables": { "account": self.account, "since": since, "until": until } })), | |
| 141 | 145 | ) | |
| 142 | 146 | .await?; | |
| 143 | − | let groups = body["data"]["viewer"]["accounts"][0]["containersMetricsAdaptiveGroups"] | |
| 147 | + | let groups = body["data"]["viewer"]["accounts"][0]["containersUsageAdaptiveGroups"] | |
| 144 | 148 | .as_array() | |
| 145 | 149 | .cloned() | |
| 146 | 150 | .unwrap_or_default(); | |
| 147 | − | Ok(groups.iter().map(|g| g["sum"]["containerUptime"].as_f64().unwrap_or(0.0)).sum()) | |
| 151 | + | Ok(groups.iter().fold(ContainerUsage::default(), |total, g| ContainerUsage { | |
| 152 | + | cpu_seconds: total.cpu_seconds + g["sum"]["cpuTimeSec"].as_f64().unwrap_or(0.0), | |
| 153 | + | memory_byte_seconds: total.memory_byte_seconds + g["sum"]["allocatedMemory"].as_f64().unwrap_or(0.0), | |
| 154 | + | })) | |
| 148 | 155 | } | |
| 149 | 156 | } | |
| 150 | 157 | ||
| 158 | + | #[derive(Debug, Default, Clone, Copy)] | |
| 159 | + | pub(crate) struct ContainerUsage { | |
| 160 | + | cpu_seconds: f64, | |
| 161 | + | memory_byte_seconds: f64, | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | /// g1t's sandboxes: Containers' standard-1, half a vCPU, 4 GiB, 8 GB disk. | |
| 165 | + | const SANDBOX_GIB: f64 = 4.0; | |
| 166 | + | const SANDBOX_DISK_GB: f64 = 8.0; | |
| 167 | + | const GIB: f64 = 1024.0 * 1024.0 * 1024.0; | |
| 168 | + | ||
| 169 | + | /// Cloudflare's published Containers rates, in dollars, used for any rate | |
| 170 | + | /// the bill does not show yet (while usage is inside the included amount). | |
| 171 | + | const LIST_MEMORY_GIB_SECOND: f64 = 0.000_002_5; | |
| 172 | + | const LIST_DISK_GB_SECOND: f64 = 0.000_000_07; | |
| 173 | + | const LIST_VCPU_SECOND: f64 = 0.000_02; | |
| 174 | + | ||
| 175 | + | /// What one second of a sandbox costs, in millionths of a dollar: its | |
| 176 | + | /// memory and disk for the whole second, and the CPU sandboxes actually | |
| 177 | + | /// use per second of running, which is billed only while busy. | |
| 178 | + | pub(crate) fn sandbox_second_micros(usage: ContainerUsage, memory: f64, disk: f64, vcpu: f64) -> Option<f64> { | |
| 179 | + | let instance_seconds = usage.memory_byte_seconds / (SANDBOX_GIB * GIB); | |
| 180 | + | if instance_seconds < 3600.0 { | |
| 181 | + | return None; | |
| 182 | + | } | |
| 183 | + | let cpu_share = usage.cpu_seconds / instance_seconds; | |
| 184 | + | Some((SANDBOX_GIB * memory + SANDBOX_DISK_GB * disk + cpu_share * vcpu) * MICROS_PER_DOLLAR as f64) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | /// A unit's marginal rate from the bill: the median, over the days that | |
| 188 | + | /// were charged, of cost over quantity. None while nothing was charged. | |
| 189 | + | pub(crate) fn billed_rate(rows: &[&UsageRow]) -> Option<f64> { | |
| 190 | + | let mut rates: Vec<f64> = rows | |
| 191 | + | .iter() | |
| 192 | + | .filter(|r| r.cost > 0.0 && r.quantity > 0.0) | |
| 193 | + | .map(|r| r.cost / r.quantity) | |
| 194 | + | .collect(); | |
| 195 | + | if rates.is_empty() { | |
| 196 | + | return None; | |
| 197 | + | } | |
| 198 | + | rates.sort_by(f64::total_cmp); | |
| 199 | + | Some(rates[rates.len() / 2]) | |
| 200 | + | } | |
| 201 | + | ||
| 151 | 202 | /// One line of Cloudflare's billable usage. | |
| 152 | 203 | #[derive(Debug, Clone)] | |
| 153 | 204 | pub(crate) struct UsageRow { | |
| 177 | 228 | period_start: text(&["ChargePeriodStart", "charge_period_start"]), | |
| 178 | 229 | period_end: text(&["ChargePeriodEnd", "charge_period_end"]), | |
| 179 | 230 | service: if family.is_empty() { service } else { format!("{family} / {service}") }, | |
| 180 | − | unit: text(&["ConsumedUnit", "PricingUnit", "consumed_unit"]), | |
| 231 | + | unit: text(&["PricingUnit", "ConsumedUnit", "consumed_unit"]), | |
| 181 | 232 | quantity: number(&["PricingQuantity", "ConsumedQuantity", "pricing_quantity"]), | |
| 182 | − | cost: number(&["ContractedCost", "BilledCost", "contracted_cost"]), | |
| 233 | + | // What g1t pays; list price if nothing was contracted. | |
| 234 | + | cost: Some(number(&["ContractedCost", "BilledCost", "contracted_cost"])) | |
| 235 | + | .filter(|cost| *cost > 0.0) | |
| 236 | + | .unwrap_or_else(|| number(&["ListCost", "list_cost"])), | |
| 183 | 237 | }) | |
| 184 | 238 | } | |
| 185 | 239 | } | |
| 288 | 342 | }) | |
| 289 | 343 | } | |
| 290 | 344 | ||
| 345 | + | /// Whether the costs have never been checked against Cloudflare's bill. | |
| 346 | + | pub(crate) async fn never_checked(&self) -> Result<bool> { | |
| 347 | + | Ok(self | |
| 348 | + | .db | |
| 349 | + | .prepare("SELECT meter FROM prices WHERE checked_at IS NOT NULL LIMIT 1") | |
| 350 | + | .first::<Value>(None) | |
| 351 | + | .await? | |
| 352 | + | .is_none()) | |
| 353 | + | } | |
| 354 | + | ||
| 291 | 355 | /// A meter's cost and price per unit, from the book. | |
| 292 | 356 | pub(crate) async fn price(&self, meter: &str) -> Result<Option<(f64, f64)>> { | |
| 293 | 357 | #[derive(Deserialize)] | |
| 429 | 493 | return Ok(()); | |
| 430 | 494 | } | |
| 431 | 495 | let now = rfc3339(now_ms()); | |
| 432 | − | let month_start = format!("{}-01", &now[..7]); | |
| 433 | 496 | let today = &now[..10]; | |
| 434 | − | let rows = keeper.billable_usage(&month_start, today).await?; | |
| 497 | + | let since = rfc3339(now_ms() - 30 * 24 * 60 * 60 * 1000); | |
| 498 | + | let rows = keeper.billable_usage(&since[..10], today).await?; | |
| 435 | 499 | for row in &rows { | |
| 436 | 500 | self.db | |
| 437 | 501 | .prepare( | |
| 453 | 517 | .await?; | |
| 454 | 518 | } | |
| 455 | 519 | ||
| 456 | − | let matching = |service: &str, unit: Option<&str>| -> (f64, f64) { | |
| 520 | + | let named = |words: &[&str]| -> Vec<&UsageRow> { | |
| 457 | 521 | rows.iter() | |
| 458 | − | .filter(|r| r.service.to_lowercase().contains(service)) | |
| 459 | − | .filter(|r| unit.is_none_or(|u| r.unit.to_lowercase().contains(u))) | |
| 460 | − | .fold((0.0, 0.0), |(q, c), r| (q + r.quantity, c + r.cost)) | |
| 522 | + | .filter(|r| { | |
| 523 | + | let service = r.service.to_lowercase(); | |
| 524 | + | words.iter().all(|word| service.contains(word)) | |
| 525 | + | }) | |
| 526 | + | .collect() | |
| 461 | 527 | }; | |
| 462 | 528 | ||
| 463 | − | // Containers: what they cost, over the seconds they ran. | |
| 464 | − | let (_, container_cost) = matching("container", None); | |
| 465 | − | if container_cost >= MIN_MEASURED_USD { | |
| 466 | − | let seconds = keeper.container_seconds(&format!("{month_start}T00:00:00Z"), &now).await?; | |
| 467 | − | if seconds > 0.0 { | |
| 468 | − | let per_second = container_cost * MICROS_PER_DOLLAR as f64 / seconds; | |
| 469 | − | for meter in ["sandbox_second", "build_second"] { | |
| 470 | − | self.measure(meter, per_second, &format!("Cloudflare billed ${container_cost:.2} for {seconds:.0} container-seconds this month")).await?; | |
| 471 | − | } | |
| 529 | + | // Containers: each resource at what the bill shows it costs, or | |
| 530 | + | // the published rate while the included amount still covers it, | |
| 531 | + | // over how much CPU g1t's sandboxes really use per second. | |
| 532 | + | let memory = billed_rate(&named(&["container memory"])); | |
| 533 | + | let disk = billed_rate(&named(&["container disk"])); | |
| 534 | + | let vcpu = billed_rate(&named(&["container vcpu"])); | |
| 535 | + | let usage = keeper.container_usage(&since[..10], today).await?; | |
| 536 | + | if let Some(per_second) = sandbox_second_micros( | |
| 537 | + | usage, | |
| 538 | + | memory.unwrap_or(LIST_MEMORY_GIB_SECOND), | |
| 539 | + | disk.unwrap_or(LIST_DISK_GB_SECOND), | |
| 540 | + | vcpu.unwrap_or(LIST_VCPU_SECOND), | |
| 541 | + | ) { | |
| 542 | + | let instance_seconds = usage.memory_byte_seconds / (SANDBOX_GIB * GIB); | |
| 543 | + | let billed = [("memory", memory), ("disk", disk), ("vCPU", vcpu)] | |
| 544 | + | .iter() | |
| 545 | + | .filter(|(_, rate)| rate.is_some()) | |
| 546 | + | .map(|(name, _)| *name) | |
| 547 | + | .collect::<Vec<_>>(); | |
| 548 | + | let reason = format!( | |
| 549 | + | "Sandboxes used {:.2} vCPU per second over {:.0} hours of Cloudflare Containers in the last 30 days; {}", | |
| 550 | + | usage.cpu_seconds / instance_seconds, | |
| 551 | + | instance_seconds / 3600.0, | |
| 552 | + | if billed.is_empty() { | |
| 553 | + | "rates are Cloudflare's published ones".to_owned() | |
| 554 | + | } else { | |
| 555 | + | format!("{} at what Cloudflare billed", billed.join(", ")) | |
| 556 | + | }, | |
| 557 | + | ); | |
| 558 | + | for meter in ["sandbox_second", "build_second"] { | |
| 559 | + | self.measure(meter, per_second, &reason).await?; | |
| 472 | 560 | } | |
| 473 | 561 | } | |
| 474 | − | // Workers for Platforms: per million requests and CPU milliseconds. | |
| 475 | − | for (meter, unit, scale) in [("app_requests", "request", 1e6), ("app_cpu", "ms", 1e6)] { | |
| 476 | − | let (quantity, cost) = matching("workers for platforms", Some(unit)); | |
| 477 | − | if cost >= MIN_MEASURED_USD && quantity > 0.0 { | |
| 478 | − | let per = cost * MICROS_PER_DOLLAR as f64 / quantity * scale; | |
| 479 | − | self.measure(meter, per, &format!("Cloudflare billed ${cost:.2} for {quantity:.0} {unit}s this month")).await?; | |
| 562 | + | // Apps run as Workers: per million requests and CPU milliseconds, | |
| 563 | + | // once the bill shows them charged. | |
| 564 | + | let app_meters: [(&str, &[&str], &str); 2] = [ | |
| 565 | + | ("app_requests", &["workers", "requests"], "requests"), | |
| 566 | + | ("app_cpu", &["workers cpu"], "CPU ms"), | |
| 567 | + | ]; | |
| 568 | + | for (meter, words, unit) in app_meters { | |
| 569 | + | if let Some(rate) = billed_rate(&named(words)) { | |
| 570 | + | let reason = format!("Cloudflare billed Workers {unit} at ${:.2} per million", rate * 1e6); | |
| 571 | + | self.measure(meter, rate * 1e6 * MICROS_PER_DOLLAR as f64, &reason).await?; | |
| 480 | 572 | } | |
| 481 | 573 | } | |
| 482 | 574 | self.db | |
| 537 | 629 | } | |
| 538 | 630 | ||
| 539 | 631 | #[test] | |
| 632 | + | fn a_sandbox_second_is_its_memory_and_disk_and_the_cpu_it_uses() { | |
| 633 | + | // An hour of sandboxes that kept a fifth of a vCPU busy. | |
| 634 | + | let usage = ContainerUsage { cpu_seconds: 720.0, memory_byte_seconds: 3600.0 * 4.0 * GIB }; | |
| 635 | + | let micros = sandbox_second_micros(usage, LIST_MEMORY_GIB_SECOND, LIST_DISK_GB_SECOND, LIST_VCPU_SECOND).unwrap(); | |
| 636 | + | // 4 x 2.5 + 8 x 0.07 + 0.2 x 20 = 14.56 | |
| 637 | + | assert!((micros - 14.56).abs() < 1e-9, "{micros}"); | |
| 638 | + | // Too little use to say anything. | |
| 639 | + | assert!(sandbox_second_micros(ContainerUsage { cpu_seconds: 1.0, memory_byte_seconds: GIB }, 1.0, 1.0, 1.0).is_none()); | |
| 640 | + | } | |
| 641 | + | ||
| 642 | + | #[test] | |
| 643 | + | fn a_billed_rate_is_the_median_of_the_charged_days() { | |
| 644 | + | let row = |quantity: f64, cost: f64| UsageRow { | |
| 645 | + | period_start: String::new(), | |
| 646 | + | period_end: String::new(), | |
| 647 | + | service: "Containers / Container Memory".into(), | |
| 648 | + | unit: "Count".into(), | |
| 649 | + | quantity, | |
| 650 | + | cost, | |
| 651 | + | }; | |
| 652 | + | let rows = [row(100.0, 0.0), row(100.0, 0.0002), row(100.0, 0.00025), row(100.0, 0.00025)]; | |
| 653 | + | assert_eq!(billed_rate(&rows.iter().collect::<Vec<_>>()), Some(0.000_002_5)); | |
| 654 | + | assert_eq!(billed_rate(&[&row(5.0, 0.0)]), None); | |
| 655 | + | } | |
| 656 | + | ||
| 657 | + | #[test] | |
| 540 | 658 | fn usage_rows_are_read_by_their_focus_names() { | |
| 541 | 659 | let row = UsageRow::from_value(&json!({ | |
| 542 | 660 | "ServiceFamilyName": "Containers", | |
| 543 | 661 | "ServiceName": "Memory", | |
| 544 | − | "ConsumedUnit": "GiB-seconds", | |
| 662 | + | "PricingUnit": "GiB-seconds", | |
| 545 | 663 | "PricingQuantity": "1200.5", | |
| 546 | 664 | "ContractedCost": 0.003, | |
| 547 | 665 | "ChargePeriodStart": "2026-10-01", |
| 859 | 859 | if let Err(error) = billing.settle_runs(&keeper).await { | |
| 860 | 860 | worker::console_error!("settling runs failed: {error}"); | |
| 861 | 861 | } | |
| 862 | − | // Once a day: check every cost against what Cloudflare billed. | |
| 863 | − | if event.cron() == keeper::DAILY { | |
| 862 | + | // Once a day, and at once if the costs were never checked: check every | |
| 863 | + | // cost against what Cloudflare billed. | |
| 864 | + | if event.cron() == keeper::DAILY || billing.never_checked().await.unwrap_or(false) { | |
| 864 | 865 | if let Err(error) = billing.reconcile(&keeper).await { | |
| 865 | 866 | worker::console_error!("checking costs against Cloudflare failed: {error}"); | |
| 866 | 867 | } |