Commit

Costs: Cloudflare's count for a pull request's working copy is shared out to its repository's workspace (repos pull_owners)

syntaqxcommitted Parentc202156Browse files
4 files+67−130/4 viewed
+1−1
2020 | Source | What | Where it lands |
2121 | --- | --- | --- |
2222 | Billable usage, `GET /accounts/{account}/billable-usage?from=&to=` | One row per service per day in FOCUS columns: `ServiceFamilyName`, `ServiceName`, `ChargePeriodStart`, `PricingQuantity`, `ContractedCost` / `BilledCost` / `ListCost`. Every product g1t uses appears once it is used: Workers, Workers for Platforms, D1, KV, R2, Queues, Containers, Durable Objects, Artifacts, Browser Rendering, Workers AI, Vectorize, Cloudflare for SaaS, Email. Inside an included amount the cost is 0. | `cost_lines`, source `billable_usage` |
23−| GraphQL `artifactsEventsAdaptiveGroups` | Artifacts' own count by `date`, `eventType` and `repositoryName`. Operations are `create`, `fork`, `push`, `pull`, `delete`; errors (`rateLimited`, `serverError`, …) are kept but not counted. | `cost_lines`, source `artifacts_events`; per workspace (from the store key `<workspace>--<repo>`) in `own_counts` as `cloudflare_git` |
23+| GraphQL `artifactsEventsAdaptiveGroups` | Artifacts' own count by `date`, `eventType` and `repositoryName`. Operations are `create`, `fork`, `push`, `pull`, `delete`; errors (`rateLimited`, `serverError`, …) are kept but not counted. | `cost_lines`, source `artifacts_events`; per workspace (from the store key `<workspace>--<repo>`; a pull request's working copy, `pulls--<id>`, is its repository's workspace's, from repos' `pull_owners`) in `own_counts` as `cloudflare_git` |
2424 | The ledger | Every charge: its cost at the price book's cost, what it was charged at price, what paid for it. | read, never written |
2525 | `pending_usage` | Month-end meters (git, storage, scans, embeddings, the cache) as they stand. | snapshotted daily into `pending_days` |
2626 | `plan_payments` | The plan's $20. | read |
+54−11
2828 //! Account Analytics Read), or the keeper's `CLOUDFLARE_USAGE_TOKEN`,
2929 //! which has both. Without either, nothing is read and nothing fails.
3030
31−use std::collections::BTreeMap;
31+use std::collections::{BTreeMap, BTreeSet};
3232
3333 use g1t_contracts::repos::{GitOperationsArgs, WorkspaceGitOperations};
3434 use g1t_contracts::time::rfc3339;
239239 }
240240
241241 /// The workspace a repository in the store belongs to: keys are
242−/// `<workspace>--<repo>`; a pull request's fork (`pulls--<id>`) says none.
243−pub(crate) fn workspace_of_store_key(key: &str) -> Option<String> {
242+/// `<workspace>--<repo>`; a pull request's working copy (`pulls--<id>`) is
243+/// its repository's workspace's, from `owners` (repos' `pull_owners`), else
244+/// no one's.
245+pub(crate) fn workspace_of_store_key(key: &str, owners: &BTreeMap<String, String>) -> Option<String> {
244246 let (workspace, rest) = key.split_once("--")?;
245− (!workspace.is_empty() && !rest.is_empty() && workspace != "pulls").then(|| workspace.to_lowercase())
247+ if workspace.is_empty() || rest.is_empty() {
248+ return None;
249+ }
250+ if workspace == "pulls" {
251+ return owners.get(rest).map(|w| w.to_lowercase());
252+ }
253+ Some(workspace.to_lowercase())
246254 }
247255
256+/// The pull request ids whose working copies Artifacts counted events for.
257+pub(crate) fn pull_ids(body: &Value) -> Vec<String> {
258+ let groups = body["data"]["viewer"]["accounts"][0]["artifactsEventsAdaptiveGroups"].as_array().cloned().unwrap_or_default();
259+ let ids: BTreeSet<String> = groups
260+ .iter()
261+ .filter_map(|g| g["dimensions"]["repositoryName"].as_str()?.strip_prefix("pulls--").map(str::to_owned))
262+ .filter(|id| !id.is_empty())
263+ .collect();
264+ ids.into_iter().collect()
265+}
266+
248267 /// Artifacts' billable operations per workspace and day, by repository
249268 /// name: how Cloudflare's own count shares out. The meter is
250269 /// `cloudflare_git`, which shares out the git bucket's cost (`margin`).
251−pub(crate) fn artifacts_by_workspace(body: &Value) -> Vec<(String, String, f64)> {
270+pub(crate) fn artifacts_by_workspace(body: &Value, owners: &BTreeMap<String, String>) -> Vec<(String, String, f64)> {
252271 let groups = body["data"]["viewer"]["accounts"][0]["artifactsEventsAdaptiveGroups"].as_array().cloned().unwrap_or_default();
253272 let mut out: BTreeMap<(String, String), f64> = BTreeMap::new();
254273 for g in &groups {
256275 if day.len() < 10 || !ARTIFACTS_OPERATIONS.contains(&format!("events_{}", slug(kind)).as_str()) {
257276 continue;
258277 }
259− let Some(workspace) = g["dimensions"]["repositoryName"].as_str().and_then(workspace_of_store_key) else { continue };
278+ let Some(workspace) = g["dimensions"]["repositoryName"].as_str().and_then(|key| workspace_of_store_key(key, owners)) else { continue };
260279 *out.entry((day[..10].to_owned(), workspace)).or_default() += g["count"].as_f64().unwrap_or(0.0);
261280 }
262281 out.into_iter().map(|((day, workspace), count)| (day, workspace, count)).collect()
437456 Ok(body) => match lines_from_artifacts(&body) {
438457 Ok(lines) => {
439458 written += self.upsert_lines(&lines, &fetched_at).await?;
440− self.keep_cloudflare_counts(&since, &until, &artifacts_by_workspace(&body), &fetched_at).await?;
459+ let owners = self.pull_owners(&pull_ids(&body)).await;
460+ self.keep_cloudflare_counts(&since, &until, &artifacts_by_workspace(&body, &owners), &fetched_at).await?;
441461 }
442462 Err(error) => problems.push(format!("Artifacts events could not be read: {error}")),
443463 },
448468
449469 /// Cloudflare's own per-workspace counts for the days, replacing what
450470 /// was kept for them.
471+ /// The workspace of each pull request's working copy, from repos;
472+ /// nothing while repos does not answer (those events stay no one's).
473+ async fn pull_owners(&self, pulls: &[String]) -> BTreeMap<String, String> {
474+ let Some(repos) = &self.repos else { return BTreeMap::new() };
475+ if pulls.is_empty() {
476+ return BTreeMap::new();
477+ }
478+ match g1t_kit::call::<_, Value>(repos, "pull_owners", &json!({ "pulls": pulls })).await {
479+ Ok(body) => body["owners"]
480+ .as_object()
481+ .map(|o| o.iter().filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_owned()))).collect())
482+ .unwrap_or_default(),
483+ Err(error) => {
484+ worker::console_error!("pull_owners: {error}");
485+ BTreeMap::new()
486+ }
487+ }
488+ }
489+
451490 async fn keep_cloudflare_counts(&self, since: &str, until: &str, counts: &[(String, String, f64)], fetched_at: &str) -> Result<()> {
452491 self.db
453492 .prepare("DELETE FROM own_counts WHERE meter = 'cloudflare_git' AND day >= ?1 AND day <= ?2")
721760 { "count": 7, "dimensions": { "date": "2026-10-05", "eventType": "fork", "repositoryName": "pulls--123" } },
722761 { "count": 3, "dimensions": { "date": "2026-10-05", "eventType": "serverError", "repositoryName": "beta--x" } }
723762 ] }] } } });
724− assert_eq!(artifacts_by_workspace(&by_repo), vec![("2026-10-05".to_string(), "acme".to_string(), 120.0)]);
725− assert_eq!(workspace_of_store_key("Acme--api"), Some("acme".into()));
726− assert_eq!(workspace_of_store_key("pulls--9"), None);
727− assert_eq!(workspace_of_store_key("plain"), None);
763+ assert_eq!(artifacts_by_workspace(&by_repo, &BTreeMap::new()), vec![("2026-10-05".to_string(), "acme".to_string(), 120.0)]);
764+ let none = BTreeMap::new();
765+ assert_eq!(workspace_of_store_key("Acme--api", &none), Some("acme".into()));
766+ assert_eq!(workspace_of_store_key("pulls--9", &none), None);
767+ assert_eq!(workspace_of_store_key("plain", &none), None);
768+ let owners = BTreeMap::from([("9".to_string(), "Acme".to_string())]);
769+ assert_eq!(workspace_of_store_key("pulls--9", &owners), Some("acme".into()));
770+ assert_eq!(workspace_of_store_key("pulls--10", &owners), None);
728771 let operations: f64 = lines
729772 .iter()
730773 .filter(|l| l.day == "2026-10-05" && ARTIFACTS_OPERATIONS.contains(&l.meter.as_str()))
+11−0
22612261 reply(&meters::usage(&repos.registry.db, &a).await?)
22622262 }
22632263 "operation_mapping" => reply(&meters::read_mapping(&repos.registry.db).await?),
2264+ // Billing: the workspace each pull request's working copy is counted
2265+ // for, so Cloudflare's own count of `pulls--<id>` shares out too.
2266+ "pull_owners" => {
2267+ #[derive(serde::Deserialize)]
2268+ struct PullOwnersArgs {
2269+ pulls: Vec<String>,
2270+ }
2271+ let a: PullOwnersArgs = args(body)?;
2272+ let pulls: Vec<String> = a.pulls.into_iter().take(500).collect();
2273+ reply(&serde_json::json!({ "owners": meters::pull_owners(&repos.registry.db, &pulls).await? }))
2274+ }
22642275 // Services only: which meters are operations, changed without a deploy.
22652276 "set_operation_mapping" => {
22662277 let row: meters::MappingRow = args(body)?;
+1−1
303303 /// The workspace of the repository each of `pulls`' working copies came
304304 /// from: as kept for a while, else read in one query. Off the request
305305 /// path (from `flush`).
306−async fn pull_owners(db: &D1Database, pulls: &[String]) -> Result<HashMap<String, String>> {
306+pub(crate) async fn pull_owners(db: &D1Database, pulls: &[String]) -> Result<HashMap<String, String>> {
307307 let now = g1t_kit::now_ms();
308308 let mut owners = HashMap::new();
309309 let mut missing = Vec::new();