| 11 | 11 | | // |
| 12 | 12 | | // CLOUDFLARE_API_TOKEN=<token with Account Analytics: Read> \ |
| 13 | 13 | | // node scripts/ops/artifacts-usage.mjs [--days 31] [--json] |
| 14 | + | // node scripts/ops/artifacts-usage.mjs --hours 2026-10-07 |
| 14 | 15 | | // |
| 16 | + | // --hours DAY shows one UTC day hour by hour (Cloudflare's operations and |
| 17 | + | // errors against `git_operations`), and the errors by message and repository: |
| 18 | + | // a fix that lands mid-day is judged on the hours after it. |
| 19 | + | // |
| 15 | 20 | | // The D1 queries run through Wrangler with the same environment (so the |
| 16 | 21 | | // token needs D1: Read too), or with CLOUDFLARE_D1_TOKEN when that is set, |
| 17 | 22 | | // or as you are logged in (`npx wrangler login`) when neither has it. |
| ⋯ |
| 32 | 37 | | }; |
| 33 | 38 | | const days = Math.min(31, Math.max(1, Number(option("--days", "31")) || 31)); |
| 34 | 39 | | const asJson = flag("--json"); |
| 40 | + | const hoursOf = option("--hours", null); |
| 41 | + | if (hoursOf && !/^\d{4}-\d{2}-\d{2}$/.test(hoursOf)) { |
| 42 | + | console.error("--hours takes a UTC day, YYYY-MM-DD"); |
| 43 | + | process.exit(2); |
| 44 | + | } |
| 35 | 45 | | |
| 36 | 46 | | const auth = cloudflareAuth(); |
| 37 | 47 | | if (!auth) { |
| ⋯ |
| 46 | 56 | | const start = new Date(end.getTime() - days * 24 * 3600 * 1000); |
| 47 | 57 | | const day = (date) => date.toISOString().slice(0, 10); |
| 48 | 58 | | |
| 59 | + | async function graphql(query, variables) { |
| 60 | + | const response = await fetch("https://api.cloudflare.com/client/v4/graphql", { |
| 61 | + | method: "POST", |
| 62 | + | headers: { ...auth, "content-type": "application/json", "user-agent": "g1t-ops" }, |
| 63 | + | body: JSON.stringify({ query, variables: { accountTag: ACCOUNT_ID, ...variables } }), |
| 64 | + | }); |
| 65 | + | const body = await response.json(); |
| 66 | + | if (!response.ok || body.errors?.length) { |
| 67 | + | throw new Error(`GraphQL: ${response.status} ${JSON.stringify(body.errors ?? body).slice(0, 600)}`); |
| 68 | + | } |
| 69 | + | return body.data?.viewer?.accounts?.[0] ?? {}; |
| 70 | + | } |
| 71 | + | |
| 72 | + | /** One UTC day by the hour: Cloudflare's operations and errors against `git_operations`. */ |
| 73 | + | async function hourly(dayText) { |
| 74 | + | const from = `${dayText}T00:00:00Z`; |
| 75 | + | const to = new Date(Date.parse(from) + 24 * 3600 * 1000).toISOString(); |
| 76 | + | const nsFilter = NAMESPACE ? `, repositoryNamespace: "${NAMESPACE.replace(/"/g, "")}"` : ""; |
| 77 | + | const query = `query ArtifactsHours($accountTag: String!, $start: Time!, $end: Time!) { |
| 78 | + | viewer { |
| 79 | + | accounts(filter: { accountTag: $accountTag }) { |
| 80 | + | hours: artifactsEventsAdaptiveGroups( |
| 81 | + | limit: 10000 |
| 82 | + | filter: { datetime_geq: $start, datetime_lt: $end${nsFilter} } |
| 83 | + | orderBy: [datetimeHour_ASC] |
| 84 | + | ) { count dimensions { datetimeHour eventKind eventType } } |
| 85 | + | errors: artifactsEventsAdaptiveGroups( |
| 86 | + | limit: 10000 |
| 87 | + | filter: { datetime_geq: $start, datetime_lt: $end, eventKind: "error"${nsFilter} } |
| 88 | + | orderBy: [count_DESC] |
| 89 | + | ) { count dimensions { eventType errorMessage repositoryName } } |
| 90 | + | } |
| 91 | + | } |
| 92 | + | }`; |
| 93 | + | const [account, operations] = await Promise.all([ |
| 94 | + | graphql(query, { start: from, end: to }), |
| 95 | + | d1( |
| 96 | + | `SELECT substr(hour, 12, 2) AS h, SUM(operations) AS operations FROM git_operations WHERE hour >= '${dayText}T00' AND hour <= '${dayText}T23' GROUP BY h`, |
| 97 | + | ), |
| 98 | + | ]); |
| 99 | + | const ours = Object.fromEntries(operations.map((row) => [row.h, Number(row.operations)])); |
| 100 | + | const types = ["pull", "push", "create", "fork", "delete"]; |
| 101 | + | const byHour = {}; |
| 102 | + | for (const group of account.hours ?? []) { |
| 103 | + | const { datetimeHour, eventKind, eventType } = group.dimensions; |
| 104 | + | const key = eventKind === "error" ? "errors" : eventType; |
| 105 | + | if (key !== "errors" && !types.includes(key)) continue; |
| 106 | + | const h = datetimeHour.slice(11, 13); |
| 107 | + | (byHour[h] ??= {})[key] = (byHour[h][key] ?? 0) + group.count; |
| 108 | + | } |
| 109 | + | console.log(`Artifacts by the hour, ${dayText} UTC${NAMESPACE ? ` (namespace ${NAMESPACE})` : ""}\n`); |
| 110 | + | console.log(["hour", ...types.map((t) => pad(`cf.${t}`, 9)), pad("cf.ops", 8), pad("g1t.ops", 8), pad("ratio", 6), pad("cf.errors", 10)].join(" ")); |
| 111 | + | let cfTotal = 0; |
| 112 | + | let ourTotal = 0; |
| 113 | + | for (let i = 0; i < 24; i++) { |
| 114 | + | const h = String(i).padStart(2, "0"); |
| 115 | + | const cf = byHour[h] ?? {}; |
| 116 | + | const cfOps = types.reduce((total, t) => total + (cf[t] ?? 0), 0); |
| 117 | + | const mine = ours[h] ?? 0; |
| 118 | + | if (!cfOps && !mine && !cf.errors) continue; |
| 119 | + | cfTotal += cfOps; |
| 120 | + | ourTotal += mine; |
| 121 | + | console.log( |
| 122 | + | [h + " ", ...types.map((t) => pad(cf[t] ?? 0, 9)), pad(cfOps, 8), pad(mine, 8), pad(mine ? (cfOps / mine).toFixed(2) : "n/a", 6), pad(cf.errors ?? 0, 10)].join(" "), |
| 123 | + | ); |
| 124 | + | } |
| 125 | + | console.log(`\nday cf.ops ${cfTotal}, g1t.ops ${ourTotal}${ourTotal ? `, ratio ${(cfTotal / ourTotal).toFixed(2)}` : ""}`); |
| 126 | + | const messages = {}; |
| 127 | + | const repositories = {}; |
| 128 | + | for (const group of account.errors ?? []) { |
| 129 | + | const { eventType, errorMessage, repositoryName } = group.dimensions; |
| 130 | + | const key = `${eventType}: ${errorMessage || "(no message)"}`; |
| 131 | + | messages[key] = (messages[key] ?? 0) + group.count; |
| 132 | + | repositories[repositoryName] = (repositories[repositoryName] ?? 0) + group.count; |
| 133 | + | } |
| 134 | + | console.log("\nErrors by message:"); |
| 135 | + | for (const [message, count] of Object.entries(messages).sort((a, b) => b[1] - a[1])) console.log(` ${pad(count, 6)} ${message}`); |
| 136 | + | console.log("Errors by repository (top 8):"); |
| 137 | + | for (const [name, count] of Object.entries(repositories).sort((a, b) => b[1] - a[1]).slice(0, 8)) console.log(` ${pad(count, 6)} ${name}`); |
| 138 | + | console.log( |
| 139 | + | "\ng1t.ops is what workspaces are counted for (billable meters only; nightly backups are g1t's own and not in it). An hour can straddle the two sides of a write by a few seconds.", |
| 140 | + | ); |
| 141 | + | } |
| 142 | + | |
| 49 | 143 | | /** Cloudflare's own count, by day, event kind and type (and namespace). */ |
| 50 | 144 | | async function cloudflare() { |
| 51 | 145 | | const query = `query ArtifactsUsage($accountTag: String!, $start: Time!, $end: Time!) { |
| ⋯ |
| 63 | 157 | | } |
| 64 | 158 | | } |
| 65 | 159 | | }`; |
| 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 ?? []; |
| 160 | + | const account = await graphql(query, { start: start.toISOString(), end: end.toISOString() }); |
| 161 | + | const groups = account.artifactsEventsAdaptiveGroups ?? []; |
| 76 | 162 | | return groups |
| 77 | 163 | | .filter((group) => !NAMESPACE || group.dimensions.repositoryNamespace === NAMESPACE) |
| 78 | 164 | | .map((group) => ({ |
| ⋯ |
| 199 | 285 | | ); |
| 200 | 286 | | } |
| 201 | 287 | | |
| 202 | | − | main().catch((error) => { |
| 288 | + | (hoursOf ? hourly(hoursOf) : main()).catch((error) => { |
| 203 | 289 | | console.error(error.message); |
| 204 | 290 | | process.exit(1); |
| 205 | 291 | | }); |