g1t/scripts/ops/artifacts-usage.mjs
| 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 | |
| 19 | import { ACCOUNT_ID, exec, jsonFrom, wranglerEnv } from "../deploy/cloudflare.mjs"; |
| 20 | import { ROOT } from "../deploy/stack.mjs"; |
| 21 | import { join } from "node:path"; |
| 22 | |
| 23 | const WRANGLER = join(ROOT, "node_modules/wrangler/bin/wrangler.js"); |
| 24 | const DATABASE = "g1t-repos"; |
| 25 | const NAMESPACE = process.env.ARTIFACTS_NAMESPACE || null; |
| 26 | |
| 27 | const args = process.argv.slice(2); |
| 28 | const flag = (name) => args.includes(name); |
| 29 | const option = (name, fallback) => { |
| 30 | const at = args.indexOf(name); |
| 31 | return at >= 0 && args[at + 1] ? args[at + 1] : fallback; |
| 32 | }; |
| 33 | const days = Math.min(31, Math.max(1, Number(option("--days", "31")) || 31)); |
| 34 | const asJson = flag("--json"); |
| 35 | |
| 36 | const token = process.env.CLOUDFLARE_API_TOKEN; |
| 37 | if (!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 | |
| 42 | const end = new Date(); |
| 43 | const start = new Date(end.getTime() - days * 24 * 3600 * 1000); |
| 44 | const day = (date) => date.toISOString().slice(0, 10); |
| 45 | |
| 46 | /** Cloudflare's own count, by day, event kind and type (and namespace). */ |
| 47 | async 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. */ |
| 86 | async 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 | |
| 97 | async 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. */ |
| 118 | export 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 | |
| 144 | const pad = (value, width) => String(value).padStart(width); |
| 145 | |
| 146 | async 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 | |
| 199 | main().catch((error) => { |
| 200 | console.error(error.message); |
| 201 | process.exit(1); |
| 202 | }); |