Skip to content

g1t/scripts/ops/artifacts-usage.mjs

291 lines13,459 bytesCodeBlame
1#!/usr/bin/env node
2// What does Cloudflare count as an Artifacts "operation"? Compares what
3// Cloudflare's analytics say happened (GraphQL `artifactsEventsAdaptiveGroups`:
4// create, fork, push, pull, delete, and errors) with what g1t metered itself
5// (repos D1: `artifacts_meters`, every interaction by kind, and
6// `git_operations`, what workspaces are counted for), day by day, and says
7// which of g1t's meters line up with each of Cloudflare's events.
8// docs/ARTIFACTS.md (R1) explains how to read it.
9//
10// Read-only: one GraphQL query, and SELECTs against the g1t-repos database.
11//
12// CLOUDFLARE_API_TOKEN=<token with Account Analytics: Read> \
13// node scripts/ops/artifacts-usage.mjs [--days 31] [--json]
14// node scripts/ops/artifacts-usage.mjs --hours 2026-10-07
15//
16// --hours DAY shows one UTC day hour by hour (Cloudflare's operations and
17// errors against `git_operations`), and the errors by message and repository:
18// a fix that lands mid-day is judged on the hours after it.
19//
20// The D1 queries run through Wrangler with the same environment (so the
21// token needs D1: Read too), or with CLOUDFLARE_D1_TOKEN when that is set,
22// or as you are logged in (`npx wrangler login`) when neither has it.
23
24import { ACCOUNT_ID, cloudflareAuth, exec, jsonFrom, wranglerEnv } from "../deploy/cloudflare.mjs";
25import { ROOT } from "../deploy/stack.mjs";
26import { join } from "node:path";
27
28const WRANGLER = join(ROOT, "node_modules/wrangler/bin/wrangler.js");
29const DATABASE = "g1t-repos";
30const NAMESPACE = process.env.ARTIFACTS_NAMESPACE || null;
31
32const args = process.argv.slice(2);
33const flag = (name) => args.includes(name);
34const option = (name, fallback) => {
35 const at = args.indexOf(name);
36 return at >= 0 && args[at + 1] ? args[at + 1] : fallback;
37};
38const days = Math.min(31, Math.max(1, Number(option("--days", "31")) || 31));
39const asJson = flag("--json");
40const hoursOf = option("--hours", null);
41if (hoursOf && !/^\d{4}-\d{2}-\d{2}$/.test(hoursOf)) {
42 console.error("--hours takes a UTC day, YYYY-MM-DD");
43 process.exit(2);
44}
45
46const auth = cloudflareAuth();
47if (!auth) {
48 console.error(
49 "Set CLOUDFLARE_API_TOKEN to a token with Account Analytics: Read on account " + ACCOUNT_ID +
50 ", or CLOUDFLARE_API_KEY and CLOUDFLARE_EMAIL.",
51 );
52 process.exit(2);
53}
54
55const end = new Date();
56const start = new Date(end.getTime() - days * 24 * 3600 * 1000);
57const day = (date) => date.toISOString().slice(0, 10);
58
59async function graphql(query, variables) {
60 const response = await fetch("https://api.cloudflare.com/client/v4/graphql", {
61 method: "POST",
62 headers: { ...auth, "content-type": "application/json", "user-agent": "g1t-ops" },
63 body: JSON.stringify({ query, variables: { accountTag: ACCOUNT_ID, ...variables } }),
64 });
65 const body = await response.json();
66 if (!response.ok || body.errors?.length) {
67 throw new Error(`GraphQL: ${response.status} ${JSON.stringify(body.errors ?? body).slice(0, 600)}`);
68 }
69 return body.data?.viewer?.accounts?.[0] ?? {};
70}
71
72/** One UTC day by the hour: Cloudflare's operations and errors against `git_operations`. */
73async function hourly(dayText) {
74 const from = `${dayText}T00:00:00Z`;
75 const to = new Date(Date.parse(from) + 24 * 3600 * 1000).toISOString();
76 const nsFilter = NAMESPACE ? `, repositoryNamespace: "${NAMESPACE.replace(/"/g, "")}"` : "";
77 const query = `query ArtifactsHours($accountTag: String!, $start: Time!, $end: Time!) {
78 viewer {
79 accounts(filter: { accountTag: $accountTag }) {
80 hours: artifactsEventsAdaptiveGroups(
81 limit: 10000
82 filter: { datetime_geq: $start, datetime_lt: $end${nsFilter} }
83 orderBy: [datetimeHour_ASC]
84 ) { count dimensions { datetimeHour eventKind eventType } }
85 errors: artifactsEventsAdaptiveGroups(
86 limit: 10000
87 filter: { datetime_geq: $start, datetime_lt: $end, eventKind: "error"${nsFilter} }
88 orderBy: [count_DESC]
89 ) { count dimensions { eventType errorMessage repositoryName } }
90 }
91 }
92 }`;
93 const [account, operations] = await Promise.all([
94 graphql(query, { start: from, end: to }),
95 d1(
96 `SELECT substr(hour, 12, 2) AS h, SUM(operations) AS operations FROM git_operations WHERE hour >= '${dayText}T00' AND hour <= '${dayText}T23' GROUP BY h`,
97 ),
98 ]);
99 const ours = Object.fromEntries(operations.map((row) => [row.h, Number(row.operations)]));
100 const types = ["pull", "push", "create", "fork", "delete"];
101 const byHour = {};
102 for (const group of account.hours ?? []) {
103 const { datetimeHour, eventKind, eventType } = group.dimensions;
104 const key = eventKind === "error" ? "errors" : eventType;
105 if (key !== "errors" && !types.includes(key)) continue;
106 const h = datetimeHour.slice(11, 13);
107 (byHour[h] ??= {})[key] = (byHour[h][key] ?? 0) + group.count;
108 }
109 console.log(`Artifacts by the hour, ${dayText} UTC${NAMESPACE ? ` (namespace ${NAMESPACE})` : ""}\n`);
110 console.log(["hour", ...types.map((t) => pad(`cf.${t}`, 9)), pad("cf.ops", 8), pad("g1t.ops", 8), pad("ratio", 6), pad("cf.errors", 10)].join(" "));
111 let cfTotal = 0;
112 let ourTotal = 0;
113 for (let i = 0; i < 24; i++) {
114 const h = String(i).padStart(2, "0");
115 const cf = byHour[h] ?? {};
116 const cfOps = types.reduce((total, t) => total + (cf[t] ?? 0), 0);
117 const mine = ours[h] ?? 0;
118 if (!cfOps && !mine && !cf.errors) continue;
119 cfTotal += cfOps;
120 ourTotal += mine;
121 console.log(
122 [h + " ", ...types.map((t) => pad(cf[t] ?? 0, 9)), pad(cfOps, 8), pad(mine, 8), pad(mine ? (cfOps / mine).toFixed(2) : "n/a", 6), pad(cf.errors ?? 0, 10)].join(" "),
123 );
124 }
125 console.log(`\nday cf.ops ${cfTotal}, g1t.ops ${ourTotal}${ourTotal ? `, ratio ${(cfTotal / ourTotal).toFixed(2)}` : ""}`);
126 const messages = {};
127 const repositories = {};
128 for (const group of account.errors ?? []) {
129 const { eventType, errorMessage, repositoryName } = group.dimensions;
130 const key = `${eventType}: ${errorMessage || "(no message)"}`;
131 messages[key] = (messages[key] ?? 0) + group.count;
132 repositories[repositoryName] = (repositories[repositoryName] ?? 0) + group.count;
133 }
134 console.log("\nErrors by message:");
135 for (const [message, count] of Object.entries(messages).sort((a, b) => b[1] - a[1])) console.log(` ${pad(count, 6)} ${message}`);
136 console.log("Errors by repository (top 8):");
137 for (const [name, count] of Object.entries(repositories).sort((a, b) => b[1] - a[1]).slice(0, 8)) console.log(` ${pad(count, 6)} ${name}`);
138 console.log(
139 "\ng1t.ops is what workspaces are counted for (billable meters only; nightly backups are g1t's own and not in it). An hour can straddle the two sides of a write by a few seconds.",
140 );
141}
142
143/** Cloudflare's own count, by day, event kind and type (and namespace). */
144async function cloudflare() {
145 const query = `query ArtifactsUsage($accountTag: String!, $start: Time!, $end: Time!) {
146 viewer {
147 accounts(filter: { accountTag: $accountTag }) {
148 artifactsEventsAdaptiveGroups(
149 limit: 10000
150 filter: { datetime_geq: $start, datetime_leq: $end }
151 orderBy: [date_ASC]
152 ) {
153 count
154 sum { durationMs }
155 dimensions { date eventKind eventType repositoryNamespace }
156 }
157 }
158 }
159 }`;
160 const account = await graphql(query, { start: start.toISOString(), end: end.toISOString() });
161 const groups = account.artifactsEventsAdaptiveGroups ?? [];
162 return groups
163 .filter((group) => !NAMESPACE || group.dimensions.repositoryNamespace === NAMESPACE)
164 .map((group) => ({
165 day: group.dimensions.date,
166 kind: group.dimensions.eventKind,
167 type: group.dimensions.eventType,
168 namespace: group.dimensions.repositoryNamespace,
169 count: group.count,
170 ms: group.sum?.durationMs ?? 0,
171 }));
172}
173
174/** A read-only query against the repos database. */
175async function d1(sql) {
176 const env = { ...wranglerEnv({ ...process.env, CI: "true" }) };
177 if (process.env.CLOUDFLARE_D1_TOKEN) env.CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_D1_TOKEN;
178 const { code, out } = await exec(process.execPath, [WRANGLER, "d1", "execute", DATABASE, "--remote", "--json", "--command", sql], {
179 cwd: join(ROOT, "services/repos"),
180 env,
181 });
182 if (code !== 0) throw new Error(out.slice(-600));
183 return jsonFrom(out)[0]?.results ?? [];
184}
185
186async function ours() {
187 const since = day(start);
188 const operations = await d1(
189 `SELECT substr(hour, 1, 10) AS day, SUM(operations) AS operations FROM git_operations WHERE hour >= '${since}' GROUP BY day ORDER BY day`,
190 );
191 let meters = [];
192 let mapping = [];
193 try {
194 meters = await d1(
195 `SELECT day, meter, SUM(count) AS count, SUM(bytes_in) AS bytes_in, SUM(bytes_out) AS bytes_out FROM artifacts_meters WHERE day >= '${since}'` +
196 (NAMESPACE ? ` AND store = '${NAMESPACE.replace(/'/g, "")}'` : "") +
197 " GROUP BY day, meter ORDER BY day, meter",
198 );
199 mapping = await d1("SELECT meter, cost_operations, billable_operations FROM operation_mapping ORDER BY meter");
200 } catch (error) {
201 console.error(`(artifacts_meters not readable yet: migration 0011 not applied? ${String(error.message).split("\n")[0]})`);
202 }
203 return { operations, meters, mapping };
204}
205
206/** Cloudflare's events against combinations of g1t's meters: which line up. */
207export function candidates(cfTotals, meterTotals) {
208 const sum = (names) => names.reduce((total, name) => total + (meterTotals[name] ?? 0), 0);
209 const options = {
210 pull: [
211 ["git.fetch"],
212 ["git.fetch", "internal.git.fetch"],
213 ["git.fetch", "internal.git.fetch", "git.ls_refs"],
214 ["git.fetch", "internal.git.fetch", "git.ls_refs", "git.info_refs", "internal.git.info_refs"],
215 ["git.fetch", "internal.git.fetch", "git.ls_refs", "git.info_refs", "internal.git.info_refs", "cache.info_refs", "cache.ls_refs"],
216 ],
217 push: [["git.receive_pack"], ["git.receive_pack", "internal.git.receive_pack"]],
218 create: [["binding.create"]],
219 fork: [["binding.fork"]],
220 delete: [["binding.delete"]],
221 };
222 const rows = [];
223 for (const [type, combos] of Object.entries(options)) {
224 const theirs = cfTotals[type] ?? 0;
225 for (const combo of combos) {
226 const mine = sum(combo);
227 rows.push({ type, cloudflare: theirs, meters: combo.join(" + "), g1t: mine, ratio: mine ? theirs / mine : null });
228 }
229 }
230 return rows;
231}
232
233const pad = (value, width) => String(value).padStart(width);
234
235async function main() {
236 const [events, mine] = await Promise.all([cloudflare(), ours()]);
237 const cfTotals = {};
238 const cfByDay = {};
239 for (const event of events) {
240 const key = event.kind === "error" ? `error:${event.type}` : event.type;
241 cfTotals[key] = (cfTotals[key] ?? 0) + event.count;
242 (cfByDay[event.day] ??= {})[key] = (cfByDay[event.day]?.[key] ?? 0) + event.count;
243 }
244 const meterTotals = {};
245 const meterByDay = {};
246 for (const row of mine.meters) {
247 meterTotals[row.meter] = (meterTotals[row.meter] ?? 0) + Number(row.count);
248 (meterByDay[row.day] ??= {})[row.meter] = Number(row.count);
249 }
250 const opsByDay = Object.fromEntries(mine.operations.map((row) => [row.day, Number(row.operations)]));
251 const lined = candidates(cfTotals, meterTotals);
252 if (asJson) {
253 console.log(JSON.stringify({ from: day(start), to: day(end), cloudflare: events, meters: mine.meters, git_operations: mine.operations, mapping: mine.mapping, candidates: lined }, null, 2));
254 return;
255 }
256 console.log(`Artifacts usage ${day(start)} to ${day(end)}${NAMESPACE ? ` (namespace ${NAMESPACE})` : ""}\n`);
257 const types = ["pull", "push", "create", "fork", "delete"];
258 console.log(["day ", ...types.map((t) => pad(`cf.${t}`, 10)), pad("cf.errors", 10), pad("g1t.fetch", 10), pad("g1t.push", 10), pad("g1t.ops", 10)].join(" "));
259 const allDays = [...new Set([...Object.keys(cfByDay), ...Object.keys(meterByDay), ...Object.keys(opsByDay)])].sort();
260 for (const d of allDays) {
261 const cf = cfByDay[d] ?? {};
262 const m = meterByDay[d] ?? {};
263 const errors = Object.entries(cf).filter(([key]) => key.startsWith("error:")).reduce((total, [, n]) => total + n, 0);
264 console.log(
265 [
266 d,
267 ...types.map((t) => pad(cf[t] ?? 0, 10)),
268 pad(errors, 10),
269 pad((m["git.fetch"] ?? 0) + (m["internal.git.fetch"] ?? 0), 10),
270 pad((m["git.receive_pack"] ?? 0) + (m["internal.git.receive_pack"] ?? 0), 10),
271 pad(opsByDay[d] ?? 0, 10),
272 ].join(" "),
273 );
274 }
275 console.log("\nCloudflare totals:", JSON.stringify(cfTotals));
276 console.log("g1t meter totals: ", JSON.stringify(meterTotals));
277 console.log("\nWhich g1t meters line up with each Cloudflare event (ratio = Cloudflare / g1t; 1.00 is a match):");
278 for (const row of lined) {
279 console.log(` ${row.type.padEnd(7)} ${pad(row.cloudflare, 8)} vs ${pad(row.g1t, 8)} ${row.ratio == null ? " n/a" : row.ratio.toFixed(2).padStart(5)} ${row.meters}`);
280 }
281 console.log("\nNow counting as operations (operation_mapping):");
282 for (const row of mine.mapping) console.log(` ${row.meter.padEnd(28)} cost ${row.cost_operations} billable ${row.billable_operations}`);
283 console.log(
284 "\nThe days before 2026-10-06's meters were deployed have Cloudflare's numbers only. Errors are Cloudflare's error events (rateLimited, serverError, ...).",
285 );
286}
287
288(hoursOf ? hourly(hoursOf) : main()).catch((error) => {
289 console.error(error.message);
290 process.exit(1);
291});