g1t/scripts/ops/artifacts-usage.mjs

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