g1t/scripts/ops/artifacts-usage.mjs

205 lines9,109 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//
15// The D1 queries run through Wrangler with the same environment (so the
16// token needs D1: Read too), or with CLOUDFLARE_D1_TOKEN when that is set,
17// or as you are logged in (`npx wrangler login`) when neither has it.
18
19import { ACCOUNT_ID, cloudflareAuth, exec, jsonFrom, wranglerEnv } from "../deploy/cloudflare.mjs";
20import { ROOT } from "../deploy/stack.mjs";
21import { join } from "node:path";
22
23const WRANGLER = join(ROOT, "node_modules/wrangler/bin/wrangler.js");
24const DATABASE = "g1t-repos";
25const NAMESPACE = process.env.ARTIFACTS_NAMESPACE || null;
26
27const args = process.argv.slice(2);
28const flag = (name) => args.includes(name);
29const option = (name, fallback) => {
30 const at = args.indexOf(name);
31 return at >= 0 && args[at + 1] ? args[at + 1] : fallback;
32};
33const days = Math.min(31, Math.max(1, Number(option("--days", "31")) || 31));
34const asJson = flag("--json");
35
36const auth = cloudflareAuth();
37if (!auth) {
38 console.error(
39 "Set CLOUDFLARE_API_TOKEN to a token with Account Analytics: Read on account " + ACCOUNT_ID +
40 ", or CLOUDFLARE_API_KEY and CLOUDFLARE_EMAIL.",
41 );
42 process.exit(2);
43}
44
45const end = new Date();
46const start = new Date(end.getTime() - days * 24 * 3600 * 1000);
47const day = (date) => date.toISOString().slice(0, 10);
48
49/** Cloudflare's own count, by day, event kind and type (and namespace). */
50async function cloudflare() {
51 const query = `query ArtifactsUsage($accountTag: String!, $start: Time!, $end: Time!) {
52 viewer {
53 accounts(filter: { accountTag: $accountTag }) {
54 artifactsEventsAdaptiveGroups(
55 limit: 10000
56 filter: { datetime_geq: $start, datetime_leq: $end }
57 orderBy: [date_ASC]
58 ) {
59 count
60 sum { durationMs }
61 dimensions { date eventKind eventType repositoryNamespace }
62 }
63 }
64 }
65 }`;
66 const response = await fetch("https://api.cloudflare.com/client/v4/graphql", {
67 method: "POST",
68 headers: { ...auth, "content-type": "application/json" },
69 body: JSON.stringify({ query, variables: { accountTag: ACCOUNT_ID, start: start.toISOString(), end: end.toISOString() } }),
70 });
71 const body = await response.json();
72 if (!response.ok || body.errors?.length) {
73 throw new Error(`GraphQL: ${response.status} ${JSON.stringify(body.errors ?? body).slice(0, 600)}`);
74 }
75 const groups = body.data?.viewer?.accounts?.[0]?.artifactsEventsAdaptiveGroups ?? [];
76 return groups
77 .filter((group) => !NAMESPACE || group.dimensions.repositoryNamespace === NAMESPACE)
78 .map((group) => ({
79 day: group.dimensions.date,
80 kind: group.dimensions.eventKind,
81 type: group.dimensions.eventType,
82 namespace: group.dimensions.repositoryNamespace,
83 count: group.count,
84 ms: group.sum?.durationMs ?? 0,
85 }));
86}
87
88/** A read-only query against the repos database. */
89async function d1(sql) {
90 const env = { ...wranglerEnv({ ...process.env, CI: "true" }) };
91 if (process.env.CLOUDFLARE_D1_TOKEN) env.CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_D1_TOKEN;
92 const { code, out } = await exec(process.execPath, [WRANGLER, "d1", "execute", DATABASE, "--remote", "--json", "--command", sql], {
93 cwd: join(ROOT, "services/repos"),
94 env,
95 });
96 if (code !== 0) throw new Error(out.slice(-600));
97 return jsonFrom(out)[0]?.results ?? [];
98}
99
100async function ours() {
101 const since = day(start);
102 const operations = await d1(
103 `SELECT substr(hour, 1, 10) AS day, SUM(operations) AS operations FROM git_operations WHERE hour >= '${since}' GROUP BY day ORDER BY day`,
104 );
105 let meters = [];
106 let mapping = [];
107 try {
108 meters = await d1(
109 `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}'` +
110 (NAMESPACE ? ` AND store = '${NAMESPACE.replace(/'/g, "")}'` : "") +
111 " GROUP BY day, meter ORDER BY day, meter",
112 );
113 mapping = await d1("SELECT meter, cost_operations, billable_operations FROM operation_mapping ORDER BY meter");
114 } catch (error) {
115 console.error(`(artifacts_meters not readable yet: migration 0011 not applied? ${String(error.message).split("\n")[0]})`);
116 }
117 return { operations, meters, mapping };
118}
119
120/** Cloudflare's events against combinations of g1t's meters: which line up. */
121export function candidates(cfTotals, meterTotals) {
122 const sum = (names) => names.reduce((total, name) => total + (meterTotals[name] ?? 0), 0);
123 const options = {
124 pull: [
125 ["git.fetch"],
126 ["git.fetch", "internal.git.fetch"],
127 ["git.fetch", "internal.git.fetch", "git.ls_refs"],
128 ["git.fetch", "internal.git.fetch", "git.ls_refs", "git.info_refs", "internal.git.info_refs"],
129 ["git.fetch", "internal.git.fetch", "git.ls_refs", "git.info_refs", "internal.git.info_refs", "cache.info_refs", "cache.ls_refs"],
130 ],
131 push: [["git.receive_pack"], ["git.receive_pack", "internal.git.receive_pack"]],
132 create: [["binding.create"]],
133 fork: [["binding.fork"]],
134 delete: [["binding.delete"]],
135 };
136 const rows = [];
137 for (const [type, combos] of Object.entries(options)) {
138 const theirs = cfTotals[type] ?? 0;
139 for (const combo of combos) {
140 const mine = sum(combo);
141 rows.push({ type, cloudflare: theirs, meters: combo.join(" + "), g1t: mine, ratio: mine ? theirs / mine : null });
142 }
143 }
144 return rows;
145}
146
147const pad = (value, width) => String(value).padStart(width);
148
149async function main() {
150 const [events, mine] = await Promise.all([cloudflare(), ours()]);
151 const cfTotals = {};
152 const cfByDay = {};
153 for (const event of events) {
154 const key = event.kind === "error" ? `error:${event.type}` : event.type;
155 cfTotals[key] = (cfTotals[key] ?? 0) + event.count;
156 (cfByDay[event.day] ??= {})[key] = (cfByDay[event.day]?.[key] ?? 0) + event.count;
157 }
158 const meterTotals = {};
159 const meterByDay = {};
160 for (const row of mine.meters) {
161 meterTotals[row.meter] = (meterTotals[row.meter] ?? 0) + Number(row.count);
162 (meterByDay[row.day] ??= {})[row.meter] = Number(row.count);
163 }
164 const opsByDay = Object.fromEntries(mine.operations.map((row) => [row.day, Number(row.operations)]));
165 const lined = candidates(cfTotals, meterTotals);
166 if (asJson) {
167 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));
168 return;
169 }
170 console.log(`Artifacts usage ${day(start)} to ${day(end)}${NAMESPACE ? ` (namespace ${NAMESPACE})` : ""}\n`);
171 const types = ["pull", "push", "create", "fork", "delete"];
172 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(" "));
173 const allDays = [...new Set([...Object.keys(cfByDay), ...Object.keys(meterByDay), ...Object.keys(opsByDay)])].sort();
174 for (const d of allDays) {
175 const cf = cfByDay[d] ?? {};
176 const m = meterByDay[d] ?? {};
177 const errors = Object.entries(cf).filter(([key]) => key.startsWith("error:")).reduce((total, [, n]) => total + n, 0);
178 console.log(
179 [
180 d,
181 ...types.map((t) => pad(cf[t] ?? 0, 10)),
182 pad(errors, 10),
183 pad((m["git.fetch"] ?? 0) + (m["internal.git.fetch"] ?? 0), 10),
184 pad((m["git.receive_pack"] ?? 0) + (m["internal.git.receive_pack"] ?? 0), 10),
185 pad(opsByDay[d] ?? 0, 10),
186 ].join(" "),
187 );
188 }
189 console.log("\nCloudflare totals:", JSON.stringify(cfTotals));
190 console.log("g1t meter totals: ", JSON.stringify(meterTotals));
191 console.log("\nWhich g1t meters line up with each Cloudflare event (ratio = Cloudflare / g1t; 1.00 is a match):");
192 for (const row of lined) {
193 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}`);
194 }
195 console.log("\nNow counting as operations (operation_mapping):");
196 for (const row of mine.mapping) console.log(` ${row.meter.padEnd(28)} cost ${row.cost_operations} billable ${row.billable_operations}`);
197 console.log(
198 "\nThe days before 2026-10-06's meters were deployed have Cloudflare's numbers only. Errors are Cloudflare's error events (rateLimited, serverError, ...).",
199 );
200}
201
202main().catch((error) => {
203 console.error(error.message);
204 process.exit(1);
205});