g1t/scripts/ops/artifacts-usage.mjs

205 lines9,109 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1#!/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
g1t-runner 0.1.0 is released: signed binaries for five platforms at g1t.sh/downloads/runner; the Artifacts checks' results19import { ACCOUNT_ID, cloudflareAuth, exec, jsonFrom, wranglerEnv } from "../deploy/cloudflare.mjs";
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily20import { 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
g1t-runner 0.1.0 is released: signed binaries for five platforms at g1t.sh/downloads/runner; the Artifacts checks' results36const 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 );
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily42 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",
g1t-runner 0.1.0 is released: signed binaries for five platforms at g1t.sh/downloads/runner; the Artifacts checks' results68 headers: { ...auth, "content-type": "application/json" },
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily69 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});