| 1 | //! What Cloudflare charges g1t, day by day and product by product. |
| 2 | //! |
| 3 | //! Prices are what g1t pays plus 20%, so g1t has to know what it pays, as |
| 4 | //! Cloudflare counts it, not as g1t assumes it. Some of what g1t runs on |
| 5 | //! is in beta (Artifacts bills "operations" from 2026-10-14 without saying |
| 6 | //! exactly which calls are one), so nothing here assumes a product list: |
| 7 | //! every line Cloudflare bills is kept, whatever it is, and how a line |
| 8 | //! maps to what g1t sells is data (`cost_map`), changed without a deploy. |
| 9 | //! |
| 10 | //! Once a day (`keeper::DAILY`) billing reads: |
| 11 | //! |
| 12 | //! - **Billable usage** (`GET /accounts/{id}/billable-usage`, FOCUS |
| 13 | //! columns): one row per service per day, with its quantity and what it |
| 14 | //! cost g1t. Every Cloudflare product g1t uses appears here once it is |
| 15 | //! used: Workers, Workers for Platforms, D1, KV, R2, Queues, Containers, |
| 16 | //! Durable Objects, Artifacts, Browser Rendering, Workers AI, Vectorize, |
| 17 | //! Cloudflare for SaaS and Email. |
| 18 | //! - **Artifacts events** (GraphQL `artifactsEventsAdaptiveGroups`), by |
| 19 | //! event type and day: what Artifacts itself counted, to compare with |
| 20 | //! what g1t counted (`margin`). |
| 21 | //! |
| 22 | //! Each becomes cost lines in `cost_lines`, one per (day, source, |
| 23 | //! product, meter), upserted, so reading a day again replaces it. The bill |
| 24 | //! is read over whole billing cycles, every page of it, and priced over |
| 25 | //! each cycle (`cycle`): its included amounts are the account's, once a |
| 26 | //! cycle. The Artifacts events: the first run reads the last 31 days; after |
| 27 | //! that the last few, since Cloudflare restates recent days as usage |
| 28 | //! settles. |
| 29 | //! |
| 30 | //! The token is `CLOUDFLARE_BILLING_TOKEN` (Account: Billing Read and |
| 31 | //! Account Analytics Read), or the keeper's `CLOUDFLARE_USAGE_TOKEN`, |
| 32 | //! which has both. Without either, nothing is read and nothing fails. |
| 33 | |
| 34 | use std::collections::{BTreeMap, BTreeSet}; |
| 35 | |
| 36 | use g1t_contracts::repos::{GitOperationsArgs, WorkspaceGitOperations}; |
| 37 | use g1t_contracts::time::rfc3339; |
| 38 | use g1t_kit::now_ms; |
| 39 | use serde::Deserialize; |
| 40 | use serde_json::{Value, json}; |
| 41 | use worker::Result; |
| 42 | |
| 43 | use crate::Billing; |
| 44 | use crate::keeper::Keeper; |
| 45 | |
| 46 | /// Where a cost line came from. |
| 47 | pub(crate) const SOURCE_BILLABLE: &str = "billable_usage"; |
| 48 | pub(crate) const SOURCE_ARTIFACTS: &str = "artifacts_events"; |
| 49 | |
| 50 | /// How far back the first run reads: the 31 days GraphQL keeps. |
| 51 | pub(crate) const BACKFILL_DAYS: u64 = 31; |
| 52 | /// How many recent days every later run reads again. |
| 53 | pub(crate) const RESTATE_DAYS: u64 = 4; |
| 54 | |
| 55 | pub(crate) const DAY_MS: u64 = 24 * 60 * 60 * 1000; |
| 56 | |
| 57 | /// One day of one meter of one Cloudflare product. |
| 58 | #[derive(Clone, Debug, Default, PartialEq)] |
| 59 | pub(crate) struct CostLine { |
| 60 | /// YYYY-MM-DD, UTC. |
| 61 | pub day: String, |
| 62 | pub source: &'static str, |
| 63 | /// `containers`, `workers`, `workers_kv`, `artifacts`, … from |
| 64 | /// Cloudflare's own family name. |
| 65 | pub product: String, |
| 66 | /// The service within it, such as `container_memory_per_gib_second` |
| 67 | /// or `events_push`. |
| 68 | pub meter: String, |
| 69 | pub unit: String, |
| 70 | pub quantity: f64, |
| 71 | /// What g1t pays, in dollars: for billable usage, set over the billing |
| 72 | /// cycle by `cycle::price_lines` (Cloudflare's own cost where its lines |
| 73 | /// carry one, else the list price past the included amount). |
| 74 | pub cost_usd: f64, |
| 75 | /// The name as Cloudflare gave it, for people. |
| 76 | pub raw_name: String, |
| 77 | /// What Cloudflare's line itself said it cost (contracted, billed or |
| 78 | /// effective; never list, which is before the included amounts). |
| 79 | pub billed_usd: f64, |
| 80 | /// Of `quantity`, what is past the cycle's included amount. |
| 81 | pub billable_quantity: f64, |
| 82 | /// Where `cost_usd` came from: `cycle::BASIS_*`, or empty for lines |
| 83 | /// that are not billable usage. |
| 84 | pub basis: &'static str, |
| 85 | } |
| 86 | |
| 87 | /// `Workers for Platforms CPU ms (First 60M ms are included)` → |
| 88 | /// `workers_for_platforms_cpu_ms`: lower case, words joined by `_`, and |
| 89 | /// what is in parentheses (the included amount, which changes) left out. |
| 90 | pub(crate) fn slug(text: &str) -> String { |
| 91 | let mut out = String::new(); |
| 92 | let mut depth = 0u32; |
| 93 | let mut gap = false; |
| 94 | for c in text.chars() { |
| 95 | match c { |
| 96 | '(' => depth += 1, |
| 97 | ')' => depth = depth.saturating_sub(1), |
| 98 | _ if depth > 0 => {} |
| 99 | c if c.is_ascii_alphanumeric() => { |
| 100 | if gap && !out.is_empty() { |
| 101 | out.push('_'); |
| 102 | } |
| 103 | gap = false; |
| 104 | out.push(c.to_ascii_lowercase()); |
| 105 | } |
| 106 | _ => gap = true, |
| 107 | } |
| 108 | } |
| 109 | out |
| 110 | } |
| 111 | |
| 112 | /// The product and meter for a family and service name, such as |
| 113 | /// (`D1`, `D1 - Rows Read (first 25 billion included)`) → (`d1`, |
| 114 | /// `d1_rows_read`). Without a family, the service's first word. |
| 115 | pub(crate) fn product_and_meter(family: &str, service: &str) -> (String, String) { |
| 116 | let (family, service) = if family.trim().is_empty() { |
| 117 | match service.split_once(" / ") { |
| 118 | Some((family, service)) => (family, service), |
| 119 | None => (service.split_whitespace().next().unwrap_or("other"), service), |
| 120 | } |
| 121 | } else { |
| 122 | (family, service) |
| 123 | }; |
| 124 | let product = slug(family); |
| 125 | let product = if product.is_empty() { "other".to_owned() } else { product }; |
| 126 | // Kept whole: `Workers for Platforms Requests` under `Workers` must stay |
| 127 | // `workers_for_platforms_requests`. |
| 128 | let meter = slug(service); |
| 129 | let meter = if meter.is_empty() { "usage".to_owned() } else { meter }; |
| 130 | (product, meter) |
| 131 | } |
| 132 | |
| 133 | fn text<'a>(row: &'a Value, keys: &[&str]) -> &'a str { |
| 134 | keys.iter().find_map(|k| row[*k].as_str()).unwrap_or_default() |
| 135 | } |
| 136 | |
| 137 | fn number(row: &Value, keys: &[&str]) -> Option<f64> { |
| 138 | keys.iter() |
| 139 | .find_map(|k| row[*k].as_f64().or_else(|| row[*k].as_str().and_then(|s| s.trim().parse().ok()))) |
| 140 | } |
| 141 | |
| 142 | /// FOCUS's consumed quantity: what was used, in the meter's own units. |
| 143 | const CONSUMED: [&str; 2] = ["ConsumedQuantity", "consumed_quantity"]; |
| 144 | /// FOCUS's pricing quantity: in pricing units, which can be blocks. |
| 145 | const PRICING: [&str; 2] = ["PricingQuantity", "pricing_quantity"]; |
| 146 | /// What a line cost g1t, as Cloudflare says: contracted, billed, effective. |
| 147 | /// Never `ListCost`: that is before the included amounts. |
| 148 | const COSTS: [&[&str]; 3] = [&["ContractedCost", "contracted_cost"], &["BilledCost", "billed_cost"], &["EffectiveCost", "effective_cost"]]; |
| 149 | |
| 150 | fn billed(row: &Value) -> f64 { |
| 151 | COSTS.iter().find_map(|keys| number(row, keys).filter(|c| *c > 0.0)).unwrap_or(0.0) |
| 152 | } |
| 153 | |
| 154 | /// One row of billable usage, read leniently: the API is new, and its |
| 155 | /// field names are FOCUS's (in either case). None without a service or a |
| 156 | /// day. The quantity is what was consumed (the included amounts are applied |
| 157 | /// to it over the cycle, `cycle`), else the pricing quantity. |
| 158 | pub(crate) fn line_from_focus(row: &Value) -> Option<CostLine> { |
| 159 | let service = text(row, &["ServiceName", "service_name", "service"]); |
| 160 | let day = text(row, &["ChargePeriodStart", "charge_period_start", "UsageDate", "date"]); |
| 161 | if service.is_empty() || day.len() < 10 { |
| 162 | return None; |
| 163 | } |
| 164 | let family = text(row, &["ServiceFamilyName", "service_family_name", "ServiceCategory"]); |
| 165 | let (product, meter) = product_and_meter(family, service); |
| 166 | let consumed = number(row, &CONSUMED); |
| 167 | let billed = billed(row); |
| 168 | Some(CostLine { |
| 169 | day: day[..10].to_owned(), |
| 170 | source: SOURCE_BILLABLE, |
| 171 | product, |
| 172 | meter, |
| 173 | unit: if consumed.is_some() { |
| 174 | text(row, &["ConsumedUnit", "consumed_unit", "PricingUnit", "pricing_unit"]) |
| 175 | } else { |
| 176 | text(row, &["PricingUnit", "pricing_unit", "ConsumedUnit", "consumed_unit"]) |
| 177 | } |
| 178 | .to_owned(), |
| 179 | quantity: consumed.or_else(|| number(row, &PRICING)).unwrap_or(0.0), |
| 180 | cost_usd: billed, |
| 181 | raw_name: if family.is_empty() { service.to_owned() } else { format!("{family} / {service}") }, |
| 182 | billed_usd: billed, |
| 183 | ..CostLine::default() |
| 184 | }) |
| 185 | } |
| 186 | |
| 187 | /// What a read of billable usage got back, to tell from sudo whether it |
| 188 | /// was all of it and in which units. |
| 189 | #[derive(Clone, Debug, Default, PartialEq)] |
| 190 | pub(crate) struct ReadStats { |
| 191 | pub rows: u32, |
| 192 | pub pages: u32, |
| 193 | pub consumed_rows: u32, |
| 194 | pub pricing_only_rows: u32, |
| 195 | pub costed_rows: u32, |
| 196 | } |
| 197 | |
| 198 | pub(crate) fn read_stats(body: &Value, pages: u32) -> ReadStats { |
| 199 | let rows = body["result"].as_array().cloned().unwrap_or_default(); |
| 200 | let mut stats = ReadStats { rows: rows.len() as u32, pages, ..ReadStats::default() }; |
| 201 | for row in &rows { |
| 202 | if number(row, &CONSUMED).is_some() { |
| 203 | stats.consumed_rows += 1; |
| 204 | } else if number(row, &PRICING).is_some() { |
| 205 | stats.pricing_only_rows += 1; |
| 206 | } |
| 207 | if billed(row) > 0.0 { |
| 208 | stats.costed_rows += 1; |
| 209 | } |
| 210 | } |
| 211 | stats |
| 212 | } |
| 213 | |
| 214 | /// The query for the page after `page` of a v4 answer: by cursor where it |
| 215 | /// gives one, else by page number while `total_pages` says there are more. |
| 216 | /// None when that was the last. |
| 217 | pub(crate) fn next_page(body: &Value, page: u32) -> Option<String> { |
| 218 | let info = &body["result_info"]; |
| 219 | if body["result"].as_array().is_some_and(Vec::is_empty) { |
| 220 | return None; |
| 221 | } |
| 222 | if let Some(cursor) = info["cursor"].as_str().filter(|c| !c.is_empty()) { |
| 223 | let encoded: String = cursor |
| 224 | .bytes() |
| 225 | .map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }) |
| 226 | .collect(); |
| 227 | return Some(format!("cursor={encoded}")); |
| 228 | } |
| 229 | let total = info["total_pages"].as_u64().or_else(|| { |
| 230 | let (count, per) = (info["total_count"].as_u64()?, info["per_page"].as_u64().filter(|p| *p > 0)?); |
| 231 | Some(count.div_ceil(per)) |
| 232 | })?; |
| 233 | (u64::from(page) < total).then(|| match info["per_page"].as_u64() { |
| 234 | Some(per) => format!("page={}&per_page={per}", page + 1), |
| 235 | None => format!("page={}", page + 1), |
| 236 | }) |
| 237 | } |
| 238 | |
| 239 | /// Lines with the same key added together, in key order. Cloudflare can |
| 240 | /// give one service several rows a day (regions, tiers); an upsert of |
| 241 | /// each would keep only the last. |
| 242 | pub(crate) fn aggregate(lines: Vec<CostLine>) -> Vec<CostLine> { |
| 243 | let mut out: Vec<CostLine> = Vec::new(); |
| 244 | for line in lines { |
| 245 | match out |
| 246 | .iter_mut() |
| 247 | .find(|l| l.day == line.day && l.source == line.source && l.product == line.product && l.meter == line.meter) |
| 248 | { |
| 249 | Some(existing) => { |
| 250 | existing.quantity += line.quantity; |
| 251 | existing.cost_usd += line.cost_usd; |
| 252 | existing.billed_usd += line.billed_usd; |
| 253 | existing.billable_quantity += line.billable_quantity; |
| 254 | } |
| 255 | None => out.push(line), |
| 256 | } |
| 257 | } |
| 258 | out.sort_by(|a, b| (&a.day, a.source, &a.product, &a.meter).cmp(&(&b.day, b.source, &b.product, &b.meter))); |
| 259 | out |
| 260 | } |
| 261 | |
| 262 | /// Every line of a billable-usage answer, aggregated. Errors when |
| 263 | /// Cloudflare says it failed. |
| 264 | pub(crate) fn lines_from_billable(body: &Value) -> std::result::Result<Vec<CostLine>, String> { |
| 265 | if body["success"] == Value::Bool(false) { |
| 266 | return Err(format!("billable usage failed: {}", body["errors"])); |
| 267 | } |
| 268 | let rows = body["result"].as_array().cloned().unwrap_or_default(); |
| 269 | Ok(aggregate(rows.iter().filter_map(line_from_focus).collect())) |
| 270 | } |
| 271 | |
| 272 | /// The GraphQL query for Artifacts' events by type and day. |
| 273 | pub(crate) const ARTIFACTS_QUERY: &str = "query ($account: String!, $since: Date!, $until: Date!) { |
| 274 | viewer { accounts(filter: { accountTag: $account }) { |
| 275 | artifactsEventsAdaptiveGroups(limit: 10000, filter: { date_geq: $since, date_leq: $until }) { |
| 276 | count |
| 277 | dimensions { date eventType repositoryName } |
| 278 | } |
| 279 | } } |
| 280 | }"; |
| 281 | |
| 282 | pub(crate) fn artifacts_variables(account: &str, since: &str, until: &str) -> Value { |
| 283 | json!({ "query": ARTIFACTS_QUERY, "variables": { "account": account, "since": since, "until": until } }) |
| 284 | } |
| 285 | |
| 286 | /// Artifacts' events as lines (`events_push`, `events_pull`, …), with no |
| 287 | /// cost: billable usage carries the cost. Errors when GraphQL does. |
| 288 | pub(crate) fn lines_from_artifacts(body: &Value) -> std::result::Result<Vec<CostLine>, String> { |
| 289 | if let Some(errors) = body["errors"].as_array().filter(|e| !e.is_empty()) { |
| 290 | return Err(format!("Artifacts events failed: {}", Value::Array(errors.clone()))); |
| 291 | } |
| 292 | let groups = body["data"]["viewer"]["accounts"][0]["artifactsEventsAdaptiveGroups"] |
| 293 | .as_array() |
| 294 | .cloned() |
| 295 | .unwrap_or_default(); |
| 296 | Ok(aggregate( |
| 297 | groups |
| 298 | .iter() |
| 299 | .filter_map(|g| { |
| 300 | let day = g["dimensions"]["date"].as_str()?; |
| 301 | let kind = g["dimensions"]["eventType"].as_str()?; |
| 302 | if day.len() < 10 { |
| 303 | return None; |
| 304 | } |
| 305 | Some(CostLine { |
| 306 | day: day[..10].to_owned(), |
| 307 | source: SOURCE_ARTIFACTS, |
| 308 | product: "artifacts".to_owned(), |
| 309 | meter: format!("events_{}", slug(kind)), |
| 310 | unit: "events".to_owned(), |
| 311 | quantity: g["count"].as_f64().unwrap_or(0.0), |
| 312 | cost_usd: 0.0, |
| 313 | raw_name: format!("Artifacts / {kind}"), |
| 314 | ..CostLine::default() |
| 315 | }) |
| 316 | }) |
| 317 | .collect(), |
| 318 | )) |
| 319 | } |
| 320 | |
| 321 | /// Where a cost line came from: AI Gateway's analytics, what it priced |
| 322 | /// g1t's own provider traffic at. No longer read since g1t stopped running |
| 323 | /// agents; the lines already kept stay in the history. |
| 324 | pub(crate) const SOURCE_GATEWAY: &str = "ai_gateway"; |
| 325 | /// The meter prefix of requests Cloudflare billed itself (unified billing), |
| 326 | /// which are on Cloudflare's bill as well as here. |
| 327 | pub(crate) const GATEWAY_WHOLESALE: &str = "wholesale__"; |
| 328 | |
| 329 | /// The workspace a repository in the store belongs to: keys are |
| 330 | /// `<workspace>--<repo>`; a pull request's working copy (`pulls--<id>`) is |
| 331 | /// its repository's workspace's, from `owners` (repos' `pull_owners`), else |
| 332 | /// no one's. |
| 333 | pub(crate) fn workspace_of_store_key(key: &str, owners: &BTreeMap<String, String>) -> Option<String> { |
| 334 | let (workspace, rest) = key.split_once("--")?; |
| 335 | if workspace.is_empty() || rest.is_empty() { |
| 336 | return None; |
| 337 | } |
| 338 | if workspace == "pulls" { |
| 339 | return owners.get(rest).map(|w| w.to_lowercase()); |
| 340 | } |
| 341 | Some(workspace.to_lowercase()) |
| 342 | } |
| 343 | |
| 344 | /// The pull request ids whose working copies Artifacts counted events for. |
| 345 | pub(crate) fn pull_ids(body: &Value) -> Vec<String> { |
| 346 | let groups = body["data"]["viewer"]["accounts"][0]["artifactsEventsAdaptiveGroups"].as_array().cloned().unwrap_or_default(); |
| 347 | let ids: BTreeSet<String> = groups |
| 348 | .iter() |
| 349 | .filter_map(|g| g["dimensions"]["repositoryName"].as_str()?.strip_prefix("pulls--").map(str::to_owned)) |
| 350 | .filter(|id| !id.is_empty()) |
| 351 | .collect(); |
| 352 | ids.into_iter().collect() |
| 353 | } |
| 354 | |
| 355 | /// Artifacts' billable operations per workspace and day, by repository |
| 356 | /// name: how Cloudflare's own count shares out. The meter is |
| 357 | /// `cloudflare_git`, which shares out the git bucket's cost (`margin`). |
| 358 | pub(crate) fn artifacts_by_workspace(body: &Value, owners: &BTreeMap<String, String>) -> Vec<(String, String, f64)> { |
| 359 | let groups = body["data"]["viewer"]["accounts"][0]["artifactsEventsAdaptiveGroups"].as_array().cloned().unwrap_or_default(); |
| 360 | let mut out: BTreeMap<(String, String), f64> = BTreeMap::new(); |
| 361 | for g in &groups { |
| 362 | let (Some(day), Some(kind)) = (g["dimensions"]["date"].as_str(), g["dimensions"]["eventType"].as_str()) else { continue }; |
| 363 | if day.len() < 10 || !ARTIFACTS_OPERATIONS.contains(&format!("events_{}", slug(kind)).as_str()) { |
| 364 | continue; |
| 365 | } |
| 366 | let Some(workspace) = g["dimensions"]["repositoryName"].as_str().and_then(|key| workspace_of_store_key(key, owners)) else { continue }; |
| 367 | *out.entry((day[..10].to_owned(), workspace)).or_default() += g["count"].as_f64().unwrap_or(0.0); |
| 368 | } |
| 369 | out.into_iter().map(|((day, workspace), count)| (day, workspace, count)).collect() |
| 370 | } |
| 371 | |
| 372 | /// Artifacts' event types that are operations it bills: what its |
| 373 | /// pricing names (create, push, pull, clone) and the metrics list. |
| 374 | /// Errors such as `rateLimited` are not. |
| 375 | pub(crate) const ARTIFACTS_OPERATIONS: [&str; 5] = ["events_create", "events_fork", "events_push", "events_pull", "events_delete"]; |
| 376 | |
| 377 | /// The days to read: the 31 before today on the first run (`last` is |
| 378 | /// None), else the last few, never further back than the backfill. |
| 379 | pub(crate) fn window(last_fetched_day: Option<&str>, now_ms: u64) -> (String, String) { |
| 380 | let day = |ms: u64| g1t_contracts::time::rfc3339(ms)[..10].to_owned(); |
| 381 | let today = day(now_ms); |
| 382 | let since = match last_fetched_day { |
| 383 | None => day(now_ms.saturating_sub(BACKFILL_DAYS * DAY_MS)), |
| 384 | Some(last) => { |
| 385 | let restate = day(now_ms.saturating_sub(RESTATE_DAYS * DAY_MS)); |
| 386 | let floor = day(now_ms.saturating_sub(BACKFILL_DAYS * DAY_MS)); |
| 387 | // A gap since the last run is read too, up to the backfill. |
| 388 | let from = if last < restate.as_str() { last.to_owned() } else { restate }; |
| 389 | if from < floor { floor } else { from } |
| 390 | } |
| 391 | }; |
| 392 | (since, today) |
| 393 | } |
| 394 | |
| 395 | /// Every day from `since` to `until`, inclusive. |
| 396 | pub(crate) fn days_between(since: &str, until: &str) -> Vec<String> { |
| 397 | let start = g1t_contracts::time::parse_rfc3339(&format!("{since}T00:00:00Z")); |
| 398 | let end = g1t_contracts::time::parse_rfc3339(&format!("{until}T00:00:00Z")); |
| 399 | match (start, end) { |
| 400 | (Some(start), Some(end)) if start <= end => (0..=((end - start) / DAY_MS)) |
| 401 | .map(|n| g1t_contracts::time::rfc3339(start + n * DAY_MS)[..10].to_owned()) |
| 402 | .collect(), |
| 403 | _ => Vec::new(), |
| 404 | } |
| 405 | } |
| 406 | |
| 407 | /// A rule from `cost_map`: which Cloudflare lines feed which of g1t's |
| 408 | /// products. |
| 409 | #[derive(Clone, Debug, PartialEq, serde::Deserialize)] |
| 410 | pub(crate) struct Rule { |
| 411 | /// Cloudflare's product, as slugged here. |
| 412 | pub product: String, |
| 413 | /// A meter prefix, or `*` for any meter of the product. |
| 414 | pub meter: String, |
| 415 | /// g1t's product it is a cost of: `sandboxes`, `git`, `platform`, … |
| 416 | pub bucket: String, |
| 417 | /// The price book meter whose cost it measures, if any. |
| 418 | pub price_meter: Option<String>, |
| 419 | /// g1t's own count of the same units, to compare quantities. |
| 420 | pub own_meter: Option<String>, |
| 421 | /// How far g1t's count may be from Cloudflare's before it is drift. |
| 422 | pub drift_percent: f64, |
| 423 | } |
| 424 | |
| 425 | /// The rule for a line: its product's rule with the longest matching |
| 426 | /// meter prefix, `*` last. None means no one decided what pays for it: |
| 427 | /// a leak until someone does. |
| 428 | pub(crate) fn classify<'a>(rules: &'a [Rule], product: &str, meter: &str) -> Option<&'a Rule> { |
| 429 | rules |
| 430 | .iter() |
| 431 | .filter(|r| r.product == product && (r.meter == "*" || meter.starts_with(r.meter.as_str()))) |
| 432 | .max_by_key(|r| if r.meter == "*" { 0 } else { r.meter.len() + 1 }) |
| 433 | } |
| 434 | |
| 435 | /// What a bucket is called in sudo. |
| 436 | pub(crate) fn bucket_title(bucket: &str) -> String { |
| 437 | match bucket { |
| 438 | "sandboxes" => "Sandboxes and builds".into(), |
| 439 | "deployments" => "Deployments".into(), |
| 440 | "git" => "Git operations".into(), |
| 441 | "repo_storage" => "Repository storage".into(), |
| 442 | "actions_cache" => "Actions cache".into(), |
| 443 | "embeddings" => "Search embeddings".into(), |
| 444 | "security" => "Security scans".into(), |
| 445 | "domains" => "Custom domains".into(), |
| 446 | "models" => "Models".into(), |
| 447 | "platform" => "Running g1t (paid by the plan)".into(), |
| 448 | UNMAPPED => "Not mapped".into(), |
| 449 | other => other.replace('_', " "), |
| 450 | } |
| 451 | } |
| 452 | |
| 453 | /// The bucket of a line no rule claims. |
| 454 | pub(crate) const UNMAPPED: &str = "unmapped"; |
| 455 | |
| 456 | /// A day's count per workspace from counts "from this day to the end of |
| 457 | /// its month" (what the repos service's `git_operations` answers with a |
| 458 | /// `since`): each day's is its own less the next day's, within a month. |
| 459 | /// The last day (today, so far) is its own. |
| 460 | pub(crate) fn daily_from_cumulative(days: &[String], cumulative: &[BTreeMap<String, u64>]) -> Vec<(String, String, u64)> { |
| 461 | let mut out = Vec::new(); |
| 462 | for (index, day) in days.iter().enumerate() { |
| 463 | let Some(today) = cumulative.get(index) else { break }; |
| 464 | let next = days |
| 465 | .get(index + 1) |
| 466 | .filter(|next| next[..7] == day[..7]) |
| 467 | .and_then(|_| cumulative.get(index + 1)); |
| 468 | for (workspace, count) in today { |
| 469 | let later = next.and_then(|n| n.get(workspace)).copied().unwrap_or(0); |
| 470 | let own = count.saturating_sub(later); |
| 471 | if own > 0 { |
| 472 | out.push((day.clone(), workspace.clone(), own)); |
| 473 | } |
| 474 | } |
| 475 | } |
| 476 | out |
| 477 | } |
| 478 | |
| 479 | /// One row of the repos service's `artifacts_usage`, read leniently while |
| 480 | /// its shape settles: a day, a workspace, a raw meter and a count. |
| 481 | pub(crate) fn raw_usage_row(row: &Value) -> Option<(String, String, String, f64)> { |
| 482 | let day = text(row, &["day", "date"]); |
| 483 | let workspace = text(row, &["namespace", "workspace"]); |
| 484 | let meter = text(row, &["meter", "kind", "event", "operation"]); |
| 485 | let count = number(row, &["count", "quantity", "operations", "value"])?; |
| 486 | (day.len() >= 10 && !workspace.is_empty() && !meter.is_empty()) |
| 487 | .then(|| (day[..10].to_owned(), workspace.to_lowercase(), slug(meter), count)) |
| 488 | } |
| 489 | |
| 490 | /// The repos service's operation mapping, as `artifacts_usage` returns it |
| 491 | /// beside the rows: for each raw meter (slugged), how many operations it |
| 492 | /// is to Cloudflare (`cost_operations`) and to the customer |
| 493 | /// (`billable_operations`). Repos owns this mapping |
| 494 | /// (`set_operation_mapping`); billing only reads it. |
| 495 | pub(crate) fn operation_mapping(body: &Value) -> BTreeMap<String, (f64, f64)> { |
| 496 | body["mapping"] |
| 497 | .as_array() |
| 498 | .map(|rows| { |
| 499 | rows.iter() |
| 500 | .filter_map(|r| { |
| 501 | let meter = r["meter"].as_str()?; |
| 502 | Some((slug(meter), (r["cost_operations"].as_f64().unwrap_or(0.0), r["billable_operations"].as_f64().unwrap_or(0.0)))) |
| 503 | }) |
| 504 | .collect() |
| 505 | }) |
| 506 | .unwrap_or_default() |
| 507 | } |
| 508 | |
| 509 | /// Raw counts weighted by one column of the mapping: what Cloudflare |
| 510 | /// should count (`cost`), or what customers are charged for. |
| 511 | pub(crate) fn weighted(raw: &[(String, f64)], mapping: &BTreeMap<String, (f64, f64)>, cost: bool) -> f64 { |
| 512 | raw.iter() |
| 513 | .map(|(meter, count)| count * mapping.get(meter).map_or(0.0, |(c, b)| if cost { *c } else { *b })) |
| 514 | .sum() |
| 515 | } |
| 516 | |
| 517 | impl Billing { |
| 518 | /// Reads Cloudflare's bill into `cost_lines`: the billable usage over |
| 519 | /// whole billing cycles (`cycle::bill_since`), every page of it, priced |
| 520 | /// over each cycle (`cycle::price_lines`) and replacing what was kept |
| 521 | /// for those days; Artifacts' events for the days due (`window`). |
| 522 | /// Returns those days, how many lines, and the first day of the bill's |
| 523 | /// read. None without a token. What could not be read is added to |
| 524 | /// `problems`. |
| 525 | pub(crate) async fn read_cloudflare( |
| 526 | &self, |
| 527 | keeper: &Keeper, |
| 528 | problems: &mut Vec<String>, |
| 529 | ) -> Result<Option<(String, String, u32, String)>> { |
| 530 | if !keeper.can_read_bill() { |
| 531 | return Ok(None); |
| 532 | } |
| 533 | #[derive(Deserialize)] |
| 534 | struct Last { |
| 535 | day: Option<String>, |
| 536 | } |
| 537 | let last = self |
| 538 | .db |
| 539 | .prepare("SELECT MAX(day) AS day FROM cost_lines WHERE source = ?") |
| 540 | .bind(&[SOURCE_BILLABLE.into()])? |
| 541 | .first::<Last>(None) |
| 542 | .await? |
| 543 | .and_then(|l| l.day); |
| 544 | let (since, until) = window(last.as_deref(), now_ms()); |
| 545 | let fetched_at = rfc3339(now_ms()); |
| 546 | let mut written = 0; |
| 547 | // The bill over whole cycles, every time: Cloudflare posts a day a |
| 548 | // day or two late and restates recent ones, and a cycle's included |
| 549 | // amounts can only be applied to all of its days. |
| 550 | let anchor = self.cycle_anchor().await?; |
| 551 | let bill_since = crate::cycle::bill_since(&until, anchor, RESTATE_DAYS as u32, last.is_none()); |
| 552 | let read = keeper |
| 553 | .billable_usage_pages(&bill_since, &until) |
| 554 | .await |
| 555 | .map_err(|e| e.to_string()) |
| 556 | .and_then(|(body, pages)| Ok((lines_from_billable(&body)?, read_stats(&body, pages)))); |
| 557 | match read { |
| 558 | Ok((_, stats)) if stats.rows == 0 && last.is_some() => { |
| 559 | problems.push(format!("Cloudflare's billable usage answered no rows for {bill_since} to {until}; what was read before is kept.")); |
| 560 | self.keep_read(&bill_since, &until, &stats, &fetched_at).await?; |
| 561 | } |
| 562 | Ok((mut lines, stats)) => { |
| 563 | crate::cycle::price_lines(&mut lines, anchor); |
| 564 | // The days read are replaced whole: a meter gone from a day |
| 565 | // must not keep its old line. |
| 566 | self.db |
| 567 | .prepare("DELETE FROM cost_lines WHERE source = ?1 AND day >= ?2 AND day <= ?3") |
| 568 | .bind(&[SOURCE_BILLABLE.into(), bill_since.as_str().into(), until.as_str().into()])? |
| 569 | .run() |
| 570 | .await?; |
| 571 | written += self.upsert_lines(&lines, &fetched_at).await?; |
| 572 | self.keep_read(&bill_since, &until, &stats, &fetched_at).await?; |
| 573 | } |
| 574 | Err(error) => problems.push(format!("Cloudflare's billable usage could not be read: {error}")), |
| 575 | } |
| 576 | match keeper.graphql(artifacts_variables(keeper.account(), &since, &until)).await.map_err(|e| e.to_string()) { |
| 577 | Ok(body) => match lines_from_artifacts(&body) { |
| 578 | Ok(lines) => { |
| 579 | written += self.upsert_lines(&lines, &fetched_at).await?; |
| 580 | let owners = self.pull_owners(&pull_ids(&body)).await; |
| 581 | self.keep_cloudflare_counts(&since, &until, &artifacts_by_workspace(&body, &owners), &fetched_at).await?; |
| 582 | } |
| 583 | Err(error) => problems.push(format!("Artifacts events could not be read: {error}")), |
| 584 | }, |
| 585 | Err(error) => problems.push(format!("Artifacts events could not be read: {error}")), |
| 586 | } |
| 587 | Ok(Some((since, until, written, bill_since))) |
| 588 | } |
| 589 | |
| 590 | /// What the last read of billable usage got back (`cost_reads`). |
| 591 | async fn keep_read(&self, since: &str, until: &str, stats: &ReadStats, read_at: &str) -> Result<()> { |
| 592 | self.db |
| 593 | .prepare( |
| 594 | "INSERT INTO cost_reads (source, read_at, since, until, rows, pages, consumed_rows, pricing_only_rows, costed_rows) |
| 595 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) |
| 596 | ON CONFLICT (source) DO UPDATE SET read_at = ?2, since = ?3, until = ?4, rows = ?5, pages = ?6, |
| 597 | consumed_rows = ?7, pricing_only_rows = ?8, costed_rows = ?9", |
| 598 | ) |
| 599 | .bind(&[ |
| 600 | SOURCE_BILLABLE.into(), |
| 601 | read_at.into(), |
| 602 | since.into(), |
| 603 | until.into(), |
| 604 | stats.rows.into(), |
| 605 | stats.pages.into(), |
| 606 | stats.consumed_rows.into(), |
| 607 | stats.pricing_only_rows.into(), |
| 608 | stats.costed_rows.into(), |
| 609 | ])? |
| 610 | .run() |
| 611 | .await?; |
| 612 | Ok(()) |
| 613 | } |
| 614 | |
| 615 | /// Cloudflare's own per-workspace counts for the days, replacing what |
| 616 | /// was kept for them. |
| 617 | /// The workspace of each pull request's working copy, from repos; |
| 618 | /// nothing while repos does not answer (those events stay no one's). |
| 619 | async fn pull_owners(&self, pulls: &[String]) -> BTreeMap<String, String> { |
| 620 | let Some(repos) = &self.repos else { return BTreeMap::new() }; |
| 621 | if pulls.is_empty() { |
| 622 | return BTreeMap::new(); |
| 623 | } |
| 624 | match g1t_kit::call::<_, Value>(repos, "pull_owners", &json!({ "pulls": pulls })).await { |
| 625 | Ok(body) => body["owners"] |
| 626 | .as_object() |
| 627 | .map(|o| o.iter().filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_owned()))).collect()) |
| 628 | .unwrap_or_default(), |
| 629 | Err(error) => { |
| 630 | worker::console_error!("pull_owners: {error}"); |
| 631 | BTreeMap::new() |
| 632 | } |
| 633 | } |
| 634 | } |
| 635 | |
| 636 | async fn keep_cloudflare_counts(&self, since: &str, until: &str, counts: &[(String, String, f64)], fetched_at: &str) -> Result<()> { |
| 637 | self.db |
| 638 | .prepare("DELETE FROM own_counts WHERE meter = 'cloudflare_git' AND day >= ?1 AND day <= ?2") |
| 639 | .bind(&[since.into(), until.into()])? |
| 640 | .run() |
| 641 | .await?; |
| 642 | for chunk in counts.chunks(50) { |
| 643 | let mut statements = Vec::with_capacity(chunk.len()); |
| 644 | for (day, workspace, count) in chunk { |
| 645 | statements.push( |
| 646 | self.db |
| 647 | .prepare("INSERT OR REPLACE INTO own_counts (day, meter, workspace, quantity, fetched_at) VALUES (?, 'cloudflare_git', ?, ?, ?)") |
| 648 | .bind(&[day.as_str().into(), workspace.as_str().into(), (*count).into(), fetched_at.into()])?, |
| 649 | ); |
| 650 | } |
| 651 | self.db.batch(statements).await?; |
| 652 | } |
| 653 | Ok(()) |
| 654 | } |
| 655 | |
| 656 | /// Upserts lines, a day's line replacing what was read for it before. |
| 657 | async fn upsert_lines(&self, lines: &[CostLine], fetched_at: &str) -> Result<u32> { |
| 658 | for chunk in lines.chunks(50) { |
| 659 | let mut statements = Vec::with_capacity(chunk.len()); |
| 660 | for line in chunk { |
| 661 | statements.push( |
| 662 | self.db |
| 663 | .prepare( |
| 664 | "INSERT INTO cost_lines (day, source, product, meter, unit, quantity, cost_usd, raw_name, fetched_at, billed_usd, billable_quantity, basis) |
| 665 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) |
| 666 | ON CONFLICT (day, source, product, meter) DO UPDATE SET |
| 667 | unit = ?5, quantity = ?6, cost_usd = ?7, raw_name = ?8, fetched_at = ?9, |
| 668 | billed_usd = ?10, billable_quantity = ?11, basis = ?12", |
| 669 | ) |
| 670 | .bind(&[ |
| 671 | line.day.as_str().into(), |
| 672 | line.source.into(), |
| 673 | line.product.as_str().into(), |
| 674 | line.meter.as_str().into(), |
| 675 | line.unit.as_str().into(), |
| 676 | line.quantity.into(), |
| 677 | line.cost_usd.into(), |
| 678 | line.raw_name.as_str().into(), |
| 679 | fetched_at.into(), |
| 680 | line.billed_usd.into(), |
| 681 | line.billable_quantity.into(), |
| 682 | line.basis.into(), |
| 683 | ])?, |
| 684 | ); |
| 685 | } |
| 686 | self.db.batch(statements).await?; |
| 687 | } |
| 688 | Ok(lines.len() as u32) |
| 689 | } |
| 690 | |
| 691 | /// The mapping from Cloudflare's meters to g1t's products. |
| 692 | pub(crate) async fn rules(&self) -> Result<Vec<Rule>> { |
| 693 | #[derive(Deserialize)] |
| 694 | struct Row { |
| 695 | product: String, |
| 696 | meter: String, |
| 697 | bucket: String, |
| 698 | price_meter: Option<String>, |
| 699 | own_meter: Option<String>, |
| 700 | drift_percent: f64, |
| 701 | } |
| 702 | Ok(self |
| 703 | .db |
| 704 | .prepare("SELECT product, meter, bucket, price_meter, own_meter, drift_percent FROM cost_map") |
| 705 | .all() |
| 706 | .await? |
| 707 | .results::<Row>()? |
| 708 | .into_iter() |
| 709 | .map(|r| Rule { |
| 710 | product: r.product, |
| 711 | meter: r.meter, |
| 712 | bucket: r.bucket, |
| 713 | price_meter: r.price_meter, |
| 714 | own_meter: r.own_meter, |
| 715 | drift_percent: r.drift_percent, |
| 716 | }) |
| 717 | .collect()) |
| 718 | } |
| 719 | |
| 720 | /// g1t's own counts for the days, all from the repos service, which |
| 721 | /// owns the mapping from raw meters to operations (`operation_mapping`): |
| 722 | /// |
| 723 | /// - `git_operations`: what customers are charged for, as repos counts |
| 724 | /// it (`git_operations`, already through its mapping). |
| 725 | /// - `cost_operations`: what g1t expects Cloudflare to bill, from the |
| 726 | /// raw meters (`artifacts_usage`) and the mapping's cost column. |
| 727 | /// - `artifacts_<meter>`: each raw meter. |
| 728 | pub(crate) async fn count_own(&self, since: &str, until: &str) -> Result<()> { |
| 729 | let Some(repos) = &self.repos else { return Ok(()) }; |
| 730 | let days = days_between(since, until); |
| 731 | let fetched_at = rfc3339(now_ms()); |
| 732 | let mut rows: Vec<(String, String, String, f64)> = Vec::new(); |
| 733 | |
| 734 | // Raw meters and repos' mapping; skipped while repos does not answer. |
| 735 | if let Ok(body) = g1t_kit::call::<_, Value>(repos, "artifacts_usage", &json!({ "from": since, "to": until })).await { |
| 736 | let mapping = operation_mapping(&body); |
| 737 | let mut by: BTreeMap<(String, String), Vec<(String, f64)>> = BTreeMap::new(); |
| 738 | for (day, workspace, meter, count) in body["rows"].as_array().map(|l| l.iter().filter_map(raw_usage_row).collect::<Vec<_>>()).unwrap_or_default() { |
| 739 | by.entry((day, workspace)).or_default().push((meter, count)); |
| 740 | } |
| 741 | for ((day, workspace), counts) in by { |
| 742 | // Several stores (namespaces) can give the same meter. |
| 743 | let mut merged: BTreeMap<String, f64> = BTreeMap::new(); |
| 744 | for (meter, count) in &counts { |
| 745 | *merged.entry(meter.clone()).or_default() += count; |
| 746 | } |
| 747 | for (meter, count) in &merged { |
| 748 | rows.push((day.clone(), format!("artifacts_{meter}"), workspace.clone(), *count)); |
| 749 | } |
| 750 | if !mapping.is_empty() { |
| 751 | rows.push((day, "cost_operations".to_owned(), workspace, weighted(&counts, &mapping, true))); |
| 752 | } |
| 753 | } |
| 754 | } |
| 755 | // What customers are charged for, as repos counts it through its mapping. |
| 756 | let mut cumulative = Vec::with_capacity(days.len()); |
| 757 | for day in &days { |
| 758 | let list: Vec<WorkspaceGitOperations> = g1t_kit::call( |
| 759 | repos, |
| 760 | "git_operations", |
| 761 | &GitOperationsArgs { month: day[..7].to_owned(), since: Some(format!("{day}T00")), namespace: None }, |
| 762 | ) |
| 763 | .await?; |
| 764 | cumulative.push(list.into_iter().map(|w| (w.namespace.to_lowercase(), w.operations)).collect::<BTreeMap<_, _>>()); |
| 765 | } |
| 766 | for (day, workspace, count) in daily_from_cumulative(&days, &cumulative) { |
| 767 | rows.push((day, "git_operations".to_owned(), workspace, count as f64)); |
| 768 | } |
| 769 | |
| 770 | // Each day's counts replace what was there. |
| 771 | self.db |
| 772 | .prepare("DELETE FROM own_counts WHERE day >= ?1 AND day <= ?2 AND meter NOT LIKE 'cloudflare_%'") |
| 773 | .bind(&[since.into(), until.into()])? |
| 774 | .run() |
| 775 | .await?; |
| 776 | for chunk in rows.chunks(50) { |
| 777 | let mut statements = Vec::with_capacity(chunk.len()); |
| 778 | for (day, meter, workspace, quantity) in chunk { |
| 779 | statements.push( |
| 780 | self.db |
| 781 | .prepare( |
| 782 | "INSERT INTO own_counts (day, meter, workspace, quantity, fetched_at) VALUES (?1, ?2, ?3, ?4, ?5) |
| 783 | ON CONFLICT (day, meter, workspace) DO UPDATE SET quantity = ?4, fetched_at = ?5", |
| 784 | ) |
| 785 | .bind(&[day.as_str().into(), meter.as_str().into(), workspace.as_str().into(), (*quantity).into(), fetched_at.as_str().into()])?, |
| 786 | ); |
| 787 | } |
| 788 | self.db.batch(statements).await?; |
| 789 | } |
| 790 | Ok(()) |
| 791 | } |
| 792 | } |
| 793 | |
| 794 | #[cfg(test)] |
| 795 | mod tests { |
| 796 | use super::*; |
| 797 | |
| 798 | /// Billable usage as Cloudflare answered g1t on 2026-10-06 (two days, |
| 799 | /// trimmed), plus rows past the included amounts, which cost money. |
| 800 | fn billable_fixture() -> Value { |
| 801 | json!({ |
| 802 | "success": true, |
| 803 | "errors": [], |
| 804 | "result": [ |
| 805 | { |
| 806 | "ChargePeriodStart": "2026-10-03T00:00:00Z", |
| 807 | "ChargePeriodEnd": "2026-10-04T00:00:00Z", |
| 808 | "ServiceFamilyName": "Containers", |
| 809 | "ServiceName": "Container Memory, per GiB-Second (First 25 GiB-hours included)", |
| 810 | "PricingUnit": "Count", |
| 811 | "PricingQuantity": "14000", |
| 812 | "ContractedCost": 0, "BilledCost": 0, "ListCost": 0 |
| 813 | }, |
| 814 | { |
| 815 | "ChargePeriodStart": "2026-10-03T00:00:00Z", |
| 816 | "ServiceFamilyName": "D1", |
| 817 | "ServiceName": "D1 - Rows Read (first 25 billion included)", |
| 818 | "PricingUnit": "Count", |
| 819 | "PricingQuantity": 1200000, |
| 820 | "BilledCost": 0 |
| 821 | }, |
| 822 | { |
| 823 | "ChargePeriodStart": "2026-10-15T00:00:00Z", |
| 824 | "ServiceFamilyName": "Artifacts", |
| 825 | "ServiceName": "Artifacts Operations (First 10,000 included)", |
| 826 | "PricingUnit": "Count", |
| 827 | "PricingQuantity": "30000", |
| 828 | "BilledCost": "3.00", |
| 829 | "ListCost": 4.5 |
| 830 | }, |
| 831 | { |
| 832 | "ChargePeriodStart": "2026-10-15T00:00:00Z", |
| 833 | "ServiceFamilyName": "Artifacts", |
| 834 | "ServiceName": "Artifacts Operations (First 10,000 included)", |
| 835 | "PricingUnit": "Count", |
| 836 | "PricingQuantity": "10000", |
| 837 | "BilledCost": "1.50" |
| 838 | }, |
| 839 | { |
| 840 | "ChargePeriodStart": "2026-10-15T00:00:00Z", |
| 841 | "ServiceFamilyName": "Workers", |
| 842 | "ServiceName": "Workers for Platforms Requests (First 20M are included)", |
| 843 | "PricingUnit": "Count", |
| 844 | "PricingQuantity": 25000000, |
| 845 | "ListCost": 1.5 |
| 846 | }, |
| 847 | { "ServiceName": "No day, no line" } |
| 848 | ] |
| 849 | }) |
| 850 | } |
| 851 | |
| 852 | #[test] |
| 853 | fn names_become_products_and_meters() { |
| 854 | assert_eq!(slug("Workers for Platforms CPU ms (First 60M ms are included)"), "workers_for_platforms_cpu_ms"); |
| 855 | assert_eq!(product_and_meter("D1", "D1 - Rows Read (first 25 billion included)"), ("d1".into(), "d1_rows_read".into())); |
| 856 | assert_eq!(product_and_meter("Workers KV", "KV Read Operations (First 10M is included)"), ("workers_kv".into(), "kv_read_operations".into())); |
| 857 | assert_eq!(product_and_meter("", "Containers / Container vCPU"), ("containers".into(), "container_vcpu".into())); |
| 858 | assert_eq!(product_and_meter("Email", ""), ("email".into(), "usage".into())); |
| 859 | } |
| 860 | |
| 861 | #[test] |
| 862 | fn billable_usage_is_parsed_and_rows_of_one_meter_are_added() { |
| 863 | let lines = lines_from_billable(&billable_fixture()).unwrap(); |
| 864 | assert_eq!(lines.len(), 4, "{lines:#?}"); |
| 865 | let artifacts = lines.iter().find(|l| l.product == "artifacts").unwrap(); |
| 866 | // Two rows of the same day and meter: one line, added up. |
| 867 | assert_eq!(artifacts.meter, "artifacts_operations"); |
| 868 | assert_eq!(artifacts.quantity, 40_000.0); |
| 869 | assert!((artifacts.cost_usd - 4.5).abs() < 1e-9, "billed, not list: {}", artifacts.cost_usd); |
| 870 | let wfp = lines.iter().find(|l| l.meter.starts_with("workers_for_platforms")).unwrap(); |
| 871 | assert_eq!(wfp.product, "workers"); |
| 872 | // A list cost is before the included amounts: never what g1t pays. |
| 873 | // The cycle prices it (`cycle::price_lines`). |
| 874 | assert_eq!((wfp.cost_usd, wfp.billed_usd), (0.0, 0.0)); |
| 875 | let memory = lines.iter().find(|l| l.product == "containers").unwrap(); |
| 876 | assert_eq!((memory.day.as_str(), memory.quantity, memory.cost_usd), ("2026-10-03", 14_000.0, 0.0)); |
| 877 | assert!(lines_from_billable(&json!({ "success": false, "errors": [{ "code": 10000 }] })).is_err()); |
| 878 | } |
| 879 | |
| 880 | #[test] |
| 881 | fn the_quantity_is_what_was_consumed_not_the_pricing_blocks() { |
| 882 | // FOCUS: ConsumedQuantity in the meter's units; PricingQuantity in |
| 883 | // pricing units, which can be blocks of a million. |
| 884 | let row = json!({ |
| 885 | "ChargePeriodStart": "2026-10-07T00:00:00Z", |
| 886 | "ServiceFamilyName": "Workers", |
| 887 | "ServiceName": "Workers CPU ms (first 30M are included)", |
| 888 | "ConsumedQuantity": 11160000, "ConsumedUnit": "ms", |
| 889 | "PricingQuantity": 11.16, "PricingUnit": "1M ms", |
| 890 | "BilledCost": 0, "ListCost": 0.2232 |
| 891 | }); |
| 892 | let line = line_from_focus(&row).unwrap(); |
| 893 | assert_eq!((line.quantity, line.unit.as_str(), line.cost_usd), (11_160_000.0, "ms", 0.0)); |
| 894 | let body = json!({ "result": [row, { "ServiceName": "x", "ChargePeriodStart": "2026-10-07", "PricingQuantity": 3, "BilledCost": 0.5 }] }); |
| 895 | assert_eq!( |
| 896 | read_stats(&body, 2), |
| 897 | ReadStats { rows: 2, pages: 2, consumed_rows: 1, pricing_only_rows: 1, costed_rows: 1 } |
| 898 | ); |
| 899 | } |
| 900 | |
| 901 | #[test] |
| 902 | fn every_page_of_an_answer_is_read() { |
| 903 | let page = |info: Value| json!({ "success": true, "result": [{ "ServiceName": "x" }], "result_info": info }); |
| 904 | assert_eq!(next_page(&page(json!({ "page": 1, "per_page": 50, "total_pages": 3 })), 1).as_deref(), Some("page=2&per_page=50")); |
| 905 | assert_eq!(next_page(&page(json!({ "page": 3, "per_page": 50, "total_pages": 3 })), 3), None); |
| 906 | // From the count when the pages are not given. |
| 907 | assert_eq!(next_page(&page(json!({ "per_page": 100, "total_count": 230 })), 2).as_deref(), Some("page=3&per_page=100")); |
| 908 | assert_eq!(next_page(&page(json!({ "cursor": "a+b/c=" })), 1).as_deref(), Some("cursor=a%2Bb%2Fc%3D")); |
| 909 | assert_eq!(next_page(&page(json!({ "cursor": "" })), 1), None); |
| 910 | // No paging said, or nothing on the page: that was all. |
| 911 | assert_eq!(next_page(&json!({ "result": [{}] }), 1), None); |
| 912 | assert_eq!(next_page(&json!({ "result": [], "result_info": { "total_pages": 9 } }), 1), None); |
| 913 | } |
| 914 | |
| 915 | #[test] |
| 916 | fn reading_the_same_days_twice_gives_the_same_lines() { |
| 917 | // Idempotent: the same answer aggregates to the same keys and |
| 918 | // amounts, so the upsert replaces rather than adds. |
| 919 | let once = lines_from_billable(&billable_fixture()).unwrap(); |
| 920 | let twice = lines_from_billable(&billable_fixture()).unwrap(); |
| 921 | assert_eq!(once, twice); |
| 922 | let again = aggregate(once.clone()); |
| 923 | assert_eq!(again, once); |
| 924 | } |
| 925 | |
| 926 | #[test] |
| 927 | fn artifacts_events_are_counted_by_type_and_day() { |
| 928 | let body = json!({ |
| 929 | "data": { "viewer": { "accounts": [{ "artifactsEventsAdaptiveGroups": [ |
| 930 | { "count": 120, "dimensions": { "date": "2026-10-05", "eventType": "pull" } }, |
| 931 | { "count": 30, "dimensions": { "date": "2026-10-05", "eventType": "push" } }, |
| 932 | { "count": 2, "dimensions": { "date": "2026-10-05", "eventType": "rateLimited" } }, |
| 933 | { "count": 5, "dimensions": { "date": "2026-10-06", "eventType": "pull" } } |
| 934 | ] }] } }, |
| 935 | "errors": null |
| 936 | }); |
| 937 | let lines = lines_from_artifacts(&body).unwrap(); |
| 938 | assert_eq!(lines.len(), 4); |
| 939 | assert_eq!(lines[0].meter, "events_pull"); |
| 940 | assert_eq!(lines[0].quantity, 120.0); |
| 941 | assert!(lines.iter().any(|l| l.meter == "events_ratelimited")); |
| 942 | // By workspace, from the repository's store key; forks say none. |
| 943 | let by_repo = json!({ "data": { "viewer": { "accounts": [{ "artifactsEventsAdaptiveGroups": [ |
| 944 | { "count": 100, "dimensions": { "date": "2026-10-05", "eventType": "pull", "repositoryName": "acme--api" } }, |
| 945 | { "count": 20, "dimensions": { "date": "2026-10-05", "eventType": "push", "repositoryName": "acme--web" } }, |
| 946 | { "count": 7, "dimensions": { "date": "2026-10-05", "eventType": "fork", "repositoryName": "pulls--123" } }, |
| 947 | { "count": 3, "dimensions": { "date": "2026-10-05", "eventType": "serverError", "repositoryName": "beta--x" } } |
| 948 | ] }] } } }); |
| 949 | assert_eq!(artifacts_by_workspace(&by_repo, &BTreeMap::new()), vec![("2026-10-05".to_string(), "acme".to_string(), 120.0)]); |
| 950 | let none = BTreeMap::new(); |
| 951 | assert_eq!(workspace_of_store_key("Acme--api", &none), Some("acme".into())); |
| 952 | assert_eq!(workspace_of_store_key("pulls--9", &none), None); |
| 953 | assert_eq!(workspace_of_store_key("plain", &none), None); |
| 954 | let owners = BTreeMap::from([("9".to_string(), "Acme".to_string())]); |
| 955 | assert_eq!(workspace_of_store_key("pulls--9", &owners), Some("acme".into())); |
| 956 | assert_eq!(workspace_of_store_key("pulls--10", &owners), None); |
| 957 | let operations: f64 = lines |
| 958 | .iter() |
| 959 | .filter(|l| l.day == "2026-10-05" && ARTIFACTS_OPERATIONS.contains(&l.meter.as_str())) |
| 960 | .map(|l| l.quantity) |
| 961 | .sum(); |
| 962 | assert_eq!(operations, 150.0); |
| 963 | let failed = json!({ "data": null, "errors": [{ "message": "unknown field" }] }); |
| 964 | assert!(lines_from_artifacts(&failed).is_err()); |
| 965 | } |
| 966 | |
| 967 | #[test] |
| 968 | fn the_first_run_backfills_31_days_and_later_runs_restate_a_few() { |
| 969 | let now = g1t_contracts::time::parse_rfc3339("2026-10-06T04:17:00Z").unwrap(); |
| 970 | assert_eq!(window(None, now), ("2026-09-05".into(), "2026-10-06".into())); |
| 971 | assert_eq!(window(Some("2026-10-06"), now), ("2026-10-02".into(), "2026-10-06".into())); |
| 972 | // A week without a run: from the last day read. |
| 973 | assert_eq!(window(Some("2026-09-28"), now), ("2026-09-28".into(), "2026-10-06".into())); |
| 974 | // Never past the backfill. |
| 975 | assert_eq!(window(Some("2026-01-01"), now), ("2026-09-05".into(), "2026-10-06".into())); |
| 976 | assert_eq!(days_between("2026-09-29", "2026-10-02"), vec!["2026-09-29", "2026-09-30", "2026-10-01", "2026-10-02"]); |
| 977 | assert!(days_between("2026-10-02", "2026-10-01").is_empty()); |
| 978 | } |
| 979 | |
| 980 | #[test] |
| 981 | fn a_days_git_operations_are_its_count_less_the_next_days() { |
| 982 | let days: Vec<String> = ["2026-09-29", "2026-09-30", "2026-10-01", "2026-10-02"].iter().map(|d| d.to_string()).collect(); |
| 983 | let map = |pairs: &[(&str, u64)]| pairs.iter().map(|(w, n)| (w.to_string(), *n)).collect::<BTreeMap<_, _>>(); |
| 984 | // From each day to the end of its month. |
| 985 | let cumulative = vec![map(&[("acme", 30), ("beta", 4)]), map(&[("acme", 10)]), map(&[("acme", 7)]), map(&[("acme", 2)])]; |
| 986 | let daily = daily_from_cumulative(&days, &cumulative); |
| 987 | assert_eq!( |
| 988 | daily, |
| 989 | vec![ |
| 990 | ("2026-09-29".into(), "acme".into(), 20), |
| 991 | ("2026-09-29".into(), "beta".into(), 4), |
| 992 | // The month's last day is its own. |
| 993 | ("2026-09-30".into(), "acme".into(), 10), |
| 994 | ("2026-10-01".into(), "acme".into(), 5), |
| 995 | // Today so far. |
| 996 | ("2026-10-02".into(), "acme".into(), 2), |
| 997 | ] |
| 998 | ); |
| 999 | } |
| 1000 | |
| 1001 | #[test] |
| 1002 | fn raw_meters_are_weighted_by_the_repos_mapping() { |
| 1003 | // As repos' artifacts_usage answers: rows, and its operation_mapping. |
| 1004 | let body = json!({ |
| 1005 | "rows": [ |
| 1006 | { "day": "2026-10-15", "store": "g1t", "workspace": "Acme", "meter": "git.fetch", "count": 12, "bytes_in": 0, "bytes_out": 0 }, |
| 1007 | { "day": "2026-10-15", "store": "g1t", "workspace": "acme", "meter": "git.receive_pack", "count": 3, "bytes_in": 0, "bytes_out": 0 }, |
| 1008 | { "day": "2026-10-15", "store": "g1t", "workspace": "acme", "meter": "binding.read_blob", "count": 400, "bytes_in": 0, "bytes_out": 0 } |
| 1009 | ], |
| 1010 | "mapping": [ |
| 1011 | { "meter": "git.fetch", "cost_operations": 1, "billable_operations": 1 }, |
| 1012 | { "meter": "git.receive_pack", "cost_operations": 1, "billable_operations": 1 }, |
| 1013 | { "meter": "binding.read_blob", "cost_operations": 0, "billable_operations": 0 } |
| 1014 | ], |
| 1015 | "truncated": false |
| 1016 | }); |
| 1017 | let row = raw_usage_row(&body["rows"][0]).unwrap(); |
| 1018 | assert_eq!(row, ("2026-10-15".into(), "acme".into(), "git_fetch".into(), 12.0)); |
| 1019 | assert!(raw_usage_row(&json!({ "day": "2026-10-15", "count": 1 })).is_none()); |
| 1020 | let mut mapping = operation_mapping(&body); |
| 1021 | let raw: Vec<(String, f64)> = body["rows"].as_array().unwrap().iter().filter_map(raw_usage_row).map(|r| (r.2, r.3)).collect(); |
| 1022 | assert_eq!(weighted(&raw, &mapping, true), 15.0); |
| 1023 | assert_eq!(weighted(&raw, &mapping, false), 15.0); |
| 1024 | // Cloudflare turns out to bill binding reads: repos changes one row |
| 1025 | // (set_operation_mapping), and the bill g1t expects follows. |
| 1026 | mapping.insert("binding_read_blob".into(), (1.0, 0.0)); |
| 1027 | assert_eq!(weighted(&raw, &mapping, true), 415.0); |
| 1028 | assert_eq!(weighted(&raw, &mapping, false), 15.0); |
| 1029 | assert!(operation_mapping(&json!({})).is_empty()); |
| 1030 | } |
| 1031 | |
| 1032 | #[test] |
| 1033 | fn the_most_specific_rule_claims_a_line() { |
| 1034 | let rule = |product: &str, meter: &str, bucket: &str| Rule { |
| 1035 | product: product.into(), |
| 1036 | meter: meter.into(), |
| 1037 | bucket: bucket.into(), |
| 1038 | price_meter: None, |
| 1039 | own_meter: None, |
| 1040 | drift_percent: 10.0, |
| 1041 | }; |
| 1042 | let rules = vec![rule("workers", "*", "platform"), rule("workers", "workers_for_platforms", "deployments"), rule("artifacts", "*", "git")]; |
| 1043 | assert_eq!(classify(&rules, "workers", "workers_for_platforms_cpu_ms").unwrap().bucket, "deployments"); |
| 1044 | assert_eq!(classify(&rules, "workers", "workers_cpu_ms").unwrap().bucket, "platform"); |
| 1045 | assert_eq!(classify(&rules, "artifacts", "artifacts_operations").unwrap().bucket, "git"); |
| 1046 | assert!(classify(&rules, "browser_rendering", "browser_hours").is_none()); |
| 1047 | } |
| 1048 | } |