| 1 | #!/usr/bin/env node |
| 2 | // What is the platform using on Cloudflare? Asks Cloudflare's GraphQL |
| 3 | // Analytics API, for the month so far (UTC, from the 1st) and the last 24 |
| 4 | // hours, how much each Worker, D1 database, queue, Durable Object namespace, |
| 5 | // KV namespace and Artifacts namespace did, and prints the top of each with |
| 6 | // totals. Workers Logs are added when the account's schema has a dataset for |
| 7 | // them. |
| 8 | // |
| 9 | // Read-only: one GraphQL query per dataset and window, all at once, and a few |
| 10 | // REST listings to put names on database, queue and namespace ids. A dataset |
| 11 | // or field Cloudflare refuses is a note in the report, never the end of it. |
| 12 | // |
| 13 | // CLOUDFLARE_API_TOKEN=<token with Account Analytics: Read> \ |
| 14 | // node scripts/ops/platform-usage.mjs [--top 10] [--json] [--account <id>] |
| 15 | // |
| 16 | // --account (or CLOUDFLARE_ACCOUNT_ID) reads another account than g1t's. |
| 17 | // --json prints one object with snake_case keys and every row, not just the top. |
| 18 | // Names come from the D1, Queues, KV and Durable Objects listings when the |
| 19 | // token may read them (D1: Read, Queues: Read, Workers KV Storage: Read, |
| 20 | // Workers Scripts: Read); otherwise the ids are printed. |
| 21 | |
| 22 | import { ACCOUNT_ID, cloudflareAuth } from "../deploy/cloudflare.mjs"; |
| 23 | |
| 24 | const API = "https://api.cloudflare.com/client/v4"; |
| 25 | |
| 26 | const HELP = `node scripts/ops/platform-usage.mjs [--top N] [--json] [--account <id>] |
| 27 | |
| 28 | Cloudflare usage per Worker, D1 database, queue, Durable Object namespace, |
| 29 | KV namespace and Artifacts namespace, month to date (UTC) and the last 24 hours. |
| 30 | |
| 31 | --top N rows shown per dataset in the tables (default 10) |
| 32 | --json one JSON object, snake_case keys, every row |
| 33 | --account <id> the Cloudflare account (default CLOUDFLARE_ACCOUNT_ID, else g1t's) |
| 34 | --help this text |
| 35 | |
| 36 | Needs CLOUDFLARE_API_TOKEN (Account Analytics: Read), or CLOUDFLARE_API_KEY |
| 37 | with CLOUDFLARE_EMAIL.`; |
| 38 | |
| 39 | /** |
| 40 | * The datasets the report asks for. Each has one or more variants, tried in |
| 41 | * order: when Cloudflare refuses a field, the next variant asks for less. |
| 42 | * `names` says which REST listing puts a name on the first dimension's ids. |
| 43 | */ |
| 44 | export const DATASETS = [ |
| 45 | { |
| 46 | key: "workers", |
| 47 | label: "Workers invocations, by script", |
| 48 | dataset: "workersInvocationsAdaptive", |
| 49 | variants: [ |
| 50 | { sum: ["requests", "errors", "cpuTimeUs"], dims: ["scriptName"] }, |
| 51 | { sum: ["requests", "errors"], dims: ["scriptName"] }, |
| 52 | ], |
| 53 | }, |
| 54 | { |
| 55 | key: "d1", |
| 56 | label: "D1 rows, by database", |
| 57 | dataset: "d1AnalyticsAdaptiveGroups", |
| 58 | names: "d1", |
| 59 | variants: [ |
| 60 | { sum: ["rowsRead", "rowsWritten", "readQueries", "writeQueries"], dims: ["databaseId"] }, |
| 61 | { sum: ["rowsRead", "rowsWritten"], dims: ["databaseId"] }, |
| 62 | ], |
| 63 | }, |
| 64 | { |
| 65 | key: "queues", |
| 66 | label: "Queue operations, by queue", |
| 67 | dataset: "queueMessageOperationsAdaptiveGroups", |
| 68 | names: "queues", |
| 69 | variants: [{ sum: ["billableOperations"], dims: ["queueId"] }], |
| 70 | }, |
| 71 | { |
| 72 | key: "durable_objects", |
| 73 | label: "Durable Object requests, by script", |
| 74 | dataset: "durableObjectsInvocationsAdaptiveGroups", |
| 75 | variants: [ |
| 76 | { sum: ["requests", "errors"], dims: ["scriptName"] }, |
| 77 | { sum: ["requests"], dims: ["scriptName"] }, |
| 78 | ], |
| 79 | }, |
| 80 | { |
| 81 | key: "durable_objects_periodic", |
| 82 | label: "Durable Object time and storage, by namespace", |
| 83 | dataset: "durableObjectsPeriodicGroups", |
| 84 | names: "durable_objects", |
| 85 | variants: [ |
| 86 | { sum: ["activeTime", "cpuTime", "storageReadUnits", "storageWriteUnits"], dims: ["namespaceId"] }, |
| 87 | { sum: ["activeTime", "storageWriteUnits"], dims: ["namespaceId"] }, |
| 88 | // Periodic groups may filter by the minute rather than by datetime. |
| 89 | { sum: ["activeTime"], dims: ["namespaceId"], time: "datetimeMinute" }, |
| 90 | ], |
| 91 | }, |
| 92 | { |
| 93 | key: "kv", |
| 94 | label: "KV operations, by namespace and action", |
| 95 | dataset: "kvOperationsAdaptiveGroups", |
| 96 | names: "kv", |
| 97 | variants: [{ sum: ["requests"], dims: ["namespaceId", "actionType"] }], |
| 98 | }, |
| 99 | { |
| 100 | key: "artifacts", |
| 101 | label: "Artifacts events, by namespace and type", |
| 102 | dataset: "artifactsEventsAdaptiveGroups", |
| 103 | variants: [{ count: true, sum: ["durationMs"], dims: ["repositoryNamespace", "eventType"] }], |
| 104 | }, |
| 105 | { |
| 106 | key: "workers_logs", |
| 107 | label: "Workers Logs events, by script", |
| 108 | // Which dataset holds Workers Logs is read from the schema (logsDataset). |
| 109 | dataset: null, |
| 110 | optional: true, |
| 111 | variants: [ |
| 112 | { count: true, dims: ["scriptName"] }, |
| 113 | { count: true, dims: [] }, |
| 114 | ], |
| 115 | }, |
| 116 | ]; |
| 117 | |
| 118 | /** The two windows: the UTC month so far, and the last 24 hours. */ |
| 119 | export function windows(now = new Date()) { |
| 120 | const end = new Date(now.getTime()); |
| 121 | const monthStart = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), 1)); |
| 122 | return [ |
| 123 | { key: "month_to_date", label: "Month to date (UTC)", start: monthStart.toISOString(), end: end.toISOString() }, |
| 124 | { key: "last_24h", label: "Last 24 hours", start: new Date(end.getTime() - 24 * 3600 * 1000).toISOString(), end: end.toISOString() }, |
| 125 | ]; |
| 126 | } |
| 127 | |
| 128 | /** Which account field holds Workers Logs, from the account type's field names; null when none does. */ |
| 129 | export function logsDataset(fieldNames) { |
| 130 | const known = ["workersObservabilityEventsAdaptiveGroups", "workersLogsEventsAdaptiveGroups", "workersLogsAdaptiveGroups"]; |
| 131 | return known.find((name) => fieldNames.includes(name)) ?? fieldNames.find((name) => /^workers.*(logs|observability).*groups$/i.test(name)) ?? null; |
| 132 | } |
| 133 | |
| 134 | /** One dataset's query, for one variant. The rows come back under `rows`. */ |
| 135 | export function buildQuery(dataset, variant) { |
| 136 | const time = variant.time ?? "datetime"; |
| 137 | const fields = [ |
| 138 | variant.count ? "count" : "", |
| 139 | variant.sum?.length ? `sum { ${variant.sum.join(" ")} }` : "", |
| 140 | variant.dims.length ? `dimensions { ${variant.dims.join(" ")} }` : "", |
| 141 | ].filter(Boolean); |
| 142 | return `query PlatformUsage($accountTag: String!, $start: Time!, $end: Time!) { |
| 143 | viewer { |
| 144 | accounts(filter: { accountTag: $accountTag }) { |
| 145 | rows: ${dataset}(limit: 10000, filter: { ${time}_geq: $start, ${time}_leq: $end }) { |
| 146 | ${fields.join("\n ")} |
| 147 | } |
| 148 | } |
| 149 | } |
| 150 | }`; |
| 151 | } |
| 152 | |
| 153 | /** camelCase to snake_case, for the JSON report's keys. */ |
| 154 | export const snake = (name) => name.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); |
| 155 | |
| 156 | /** The metric names a variant reports, snake_case: `count` first, then its sums. */ |
| 157 | export const metricsOf = (variant) => [...(variant.count ? ["count"] : []), ...(variant.sum ?? []).map(snake)]; |
| 158 | |
| 159 | /** |
| 160 | * A GraphQL answer to one dataset's query as rows, one per name, summed and |
| 161 | * sorted by the first metric (largest first), with totals per metric. Throws |
| 162 | * with Cloudflare's message when the answer has errors or no such dataset. |
| 163 | */ |
| 164 | export function parseGroups(body, variant, names = {}) { |
| 165 | if (body?.errors?.length) throw new Error(body.errors.map((error) => error.message).join("; ").slice(0, 400)); |
| 166 | const groups = body?.data?.viewer?.accounts?.[0]?.rows; |
| 167 | if (!Array.isArray(groups)) throw new Error("no rows in the answer"); |
| 168 | const metrics = metricsOf(variant); |
| 169 | const byName = new Map(); |
| 170 | for (const group of groups) { |
| 171 | const parts = variant.dims.map((dim, at) => { |
| 172 | const value = group.dimensions?.[dim]; |
| 173 | const text = value == null || value === "" ? "(none)" : String(value); |
| 174 | return at === 0 ? (names[text] ?? text) : text; |
| 175 | }); |
| 176 | const name = parts.join(" / ") || "(all)"; |
| 177 | const row = byName.get(name) ?? { name, ...Object.fromEntries(metrics.map((metric) => [metric, 0])) }; |
| 178 | if (variant.count) row.count += Number(group.count ?? 0); |
| 179 | for (const field of variant.sum ?? []) row[snake(field)] += Number(group.sum?.[field] ?? 0); |
| 180 | byName.set(name, row); |
| 181 | } |
| 182 | const rows = sortRows([...byName.values()], metrics[0]); |
| 183 | return { metrics, rows, totals: totalsOf(rows, metrics) }; |
| 184 | } |
| 185 | |
| 186 | /** Rows largest first by one metric, ties by name. */ |
| 187 | export function sortRows(rows, metric) { |
| 188 | return [...rows].sort((a, b) => (b[metric] ?? 0) - (a[metric] ?? 0) || a.name.localeCompare(b.name)); |
| 189 | } |
| 190 | |
| 191 | /** Each metric summed over all rows. */ |
| 192 | export function totalsOf(rows, metrics) { |
| 193 | return Object.fromEntries(metrics.map((metric) => [metric, rows.reduce((total, row) => total + (row[metric] ?? 0), 0)])); |
| 194 | } |
| 195 | |
| 196 | /** |
| 197 | * The report from every dataset's outcome in every window. An outcome is |
| 198 | * `{ body, variant }` (a GraphQL answer) or `{ error }` or `{ skipped }`; |
| 199 | * whatever cannot be read becomes a note and the other datasets still count. |
| 200 | */ |
| 201 | export function assemble({ account, now, windowList, outcomes, names = {} }) { |
| 202 | return { |
| 203 | account, |
| 204 | generated_at: now.toISOString(), |
| 205 | windows: windowList.map((window) => { |
| 206 | const notes = []; |
| 207 | const datasets = []; |
| 208 | for (const spec of DATASETS) { |
| 209 | const outcome = outcomes[window.key]?.[spec.key]; |
| 210 | if (!outcome) continue; |
| 211 | if (outcome.skipped) { |
| 212 | notes.push(`${spec.label}: ${outcome.skipped}`); |
| 213 | continue; |
| 214 | } |
| 215 | try { |
| 216 | if (outcome.error) throw outcome.error; |
| 217 | const parsed = parseGroups(outcome.body, outcome.variant, names[spec.names] ?? {}); |
| 218 | datasets.push({ key: spec.key, label: spec.label, dataset: outcome.dataset ?? spec.dataset, ...parsed }); |
| 219 | } catch (error) { |
| 220 | notes.push(`${spec.label} (${outcome.dataset ?? spec.dataset ?? "no dataset"}): ${String(error.message ?? error).split("\n")[0]}`); |
| 221 | } |
| 222 | } |
| 223 | return { key: window.key, label: window.label, start: window.start, end: window.end, datasets, notes }; |
| 224 | }), |
| 225 | }; |
| 226 | } |
| 227 | |
| 228 | const number = (value) => (Number.isInteger(value) ? value.toLocaleString("en-US") : value.toLocaleString("en-US", { maximumFractionDigits: 2 })); |
| 229 | |
| 230 | /** The report as plain tables: the top rows of each dataset, and a totals line. */ |
| 231 | export function format(report, top = 10) { |
| 232 | const lines = [`Cloudflare usage for account ${report.account}, ${report.generated_at}`]; |
| 233 | for (const window of report.windows) { |
| 234 | lines.push("", `== ${window.label}: ${window.start} to ${window.end}`); |
| 235 | for (const set of window.datasets) { |
| 236 | lines.push("", `${set.label} (${set.dataset})`); |
| 237 | const shown = set.rows.slice(0, top); |
| 238 | const cells = [["name", ...set.metrics], ...shown.map((row) => [row.name, ...set.metrics.map((m) => number(row[m]))]), ["total", ...set.metrics.map((m) => number(set.totals[m]))]]; |
| 239 | const widths = cells[0].map((_, at) => Math.max(...cells.map((row) => String(row[at]).length))); |
| 240 | const render = (row) => " " + row.map((cell, at) => (at === 0 ? String(cell).padEnd(widths[at]) : String(cell).padStart(widths[at]))).join(" "); |
| 241 | lines.push(render(cells[0])); |
| 242 | if (!shown.length) lines.push(" (nothing in this window)"); |
| 243 | for (const row of cells.slice(1, -1)) lines.push(render(row)); |
| 244 | if (set.rows.length > shown.length) lines.push(` ... and ${set.rows.length - shown.length} more`); |
| 245 | lines.push(render(cells.at(-1))); |
| 246 | } |
| 247 | if (window.notes.length) { |
| 248 | lines.push("", "Notes:"); |
| 249 | for (const note of window.notes) lines.push(` ${note}`); |
| 250 | } |
| 251 | } |
| 252 | return lines.join("\n"); |
| 253 | } |
| 254 | |
| 255 | /** The command line: flags and the account. */ |
| 256 | export function parseArgs(argv, env = process.env) { |
| 257 | const option = (name) => { |
| 258 | const at = argv.indexOf(name); |
| 259 | return at >= 0 && argv[at + 1] && !argv[at + 1].startsWith("--") ? argv[at + 1] : null; |
| 260 | }; |
| 261 | return { |
| 262 | help: argv.includes("--help") || argv.includes("-h"), |
| 263 | json: argv.includes("--json"), |
| 264 | top: Math.max(1, Number(option("--top")) || 10), |
| 265 | account: option("--account") || env.CLOUDFLARE_ACCOUNT_ID || ACCOUNT_ID, |
| 266 | }; |
| 267 | } |
| 268 | |
| 269 | async function graphql(auth, account, query, variables = {}) { |
| 270 | const response = await fetch(`${API}/graphql`, { |
| 271 | method: "POST", |
| 272 | headers: { ...auth, "content-type": "application/json", "user-agent": "g1t-ops" }, |
| 273 | body: JSON.stringify({ query, variables: { accountTag: account, ...variables } }), |
| 274 | }); |
| 275 | const body = await response.json().catch(() => ({ errors: [{ message: `HTTP ${response.status}, not JSON` }] })); |
| 276 | if (!response.ok && !body.errors?.length) body.errors = [{ message: `HTTP ${response.status}` }]; |
| 277 | return body; |
| 278 | } |
| 279 | |
| 280 | /** The account type's field names, to skip datasets the schema lacks; null when it cannot be read. */ |
| 281 | async function schemaFields(auth, account) { |
| 282 | try { |
| 283 | const body = await graphql(auth, account, `{ __type(name: "account") { fields { name } } }`); |
| 284 | const fields = body.data?.__type?.fields; |
| 285 | return Array.isArray(fields) ? fields.map((field) => field.name) : null; |
| 286 | } catch { |
| 287 | return null; |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | /** One dataset in one window: each variant in turn until one is answered. */ |
| 292 | async function ask(auth, account, dataset, spec, window) { |
| 293 | let last = null; |
| 294 | for (const variant of spec.variants) { |
| 295 | try { |
| 296 | const body = await graphql(auth, account, buildQuery(dataset, variant), { start: window.start, end: window.end }); |
| 297 | if (!body.errors?.length) return { body, variant, dataset }; |
| 298 | last = { body, variant, dataset }; |
| 299 | } catch (error) { |
| 300 | last = { error, dataset }; |
| 301 | } |
| 302 | } |
| 303 | return last; |
| 304 | } |
| 305 | |
| 306 | /** A REST listing as id to name; empty when the token may not read it. */ |
| 307 | async function listing(auth, path, id, name) { |
| 308 | const out = {}; |
| 309 | try { |
| 310 | for (let page = 1; page <= 10; page++) { |
| 311 | const response = await fetch(`${API}${path}${path.includes("?") ? "&" : "?"}per_page=100&page=${page}`, { headers: { ...auth, "user-agent": "g1t-ops" } }); |
| 312 | const body = await response.json(); |
| 313 | if (!response.ok || !Array.isArray(body.result)) break; |
| 314 | for (const item of body.result) if (item[id]) out[item[id]] = item[name] ?? item[id]; |
| 315 | if (body.result.length < 100) break; |
| 316 | } |
| 317 | } catch { |
| 318 | // Ids stand in for names. |
| 319 | } |
| 320 | return out; |
| 321 | } |
| 322 | |
| 323 | async function main() { |
| 324 | const options = parseArgs(process.argv.slice(2)); |
| 325 | if (options.help) { |
| 326 | console.log(HELP); |
| 327 | return 0; |
| 328 | } |
| 329 | const auth = cloudflareAuth(); |
| 330 | if (!auth) { |
| 331 | console.error(`Set CLOUDFLARE_API_TOKEN to a token with Account Analytics: Read on account ${options.account}, or CLOUDFLARE_API_KEY and CLOUDFLARE_EMAIL.`); |
| 332 | return 2; |
| 333 | } |
| 334 | const now = new Date(); |
| 335 | const windowList = windows(now); |
| 336 | const account = options.account; |
| 337 | const base = `/accounts/${account}`; |
| 338 | const [fields, d1, queues, kv, durable] = await Promise.all([ |
| 339 | schemaFields(auth, account), |
| 340 | listing(auth, `${base}/d1/database`, "uuid", "name"), |
| 341 | listing(auth, `${base}/queues`, "queue_id", "queue_name"), |
| 342 | listing(auth, `${base}/storage/kv/namespaces`, "id", "title"), |
| 343 | listing(auth, `${base}/workers/durable_objects/namespaces`, "id", "name"), |
| 344 | ]); |
| 345 | const names = { d1, queues, kv, durable_objects: durable }; |
| 346 | const outcomes = {}; |
| 347 | const jobs = []; |
| 348 | for (const window of windowList) { |
| 349 | outcomes[window.key] = {}; |
| 350 | for (const spec of DATASETS) { |
| 351 | const dataset = spec.dataset ?? (fields ? logsDataset(fields) : null); |
| 352 | if (!dataset) { |
| 353 | if (!spec.optional) outcomes[window.key][spec.key] = { skipped: "no dataset" }; |
| 354 | continue; |
| 355 | } |
| 356 | if (fields && !fields.includes(dataset)) { |
| 357 | if (!spec.optional) outcomes[window.key][spec.key] = { skipped: `${dataset} is not in this account's schema` }; |
| 358 | continue; |
| 359 | } |
| 360 | jobs.push(ask(auth, account, dataset, spec, window).then((outcome) => (outcomes[window.key][spec.key] = outcome))); |
| 361 | } |
| 362 | } |
| 363 | await Promise.all(jobs); |
| 364 | const report = assemble({ account, now, windowList, outcomes, names }); |
| 365 | console.log(options.json ? JSON.stringify(report, null, 2) : format(report, options.top)); |
| 366 | return 0; |
| 367 | } |
| 368 | |
| 369 | if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/ops/platform-usage.mjs")) { |
| 370 | main().then( |
| 371 | (code) => process.exit(code), |
| 372 | (error) => { |
| 373 | console.error(`platform-usage: ${error.message}`); |
| 374 | process.exit(1); |
| 375 | }, |
| 376 | ); |
| 377 | } |