flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/scripts/ops/artifacts-usage.mjs

202 lines9,077 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, 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});