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.
| Merge remote-tracking branch 'origin/main' into workspace-chat | 1 | #!/usr/bin/env node |
| Comments and the audit log stop speaking of g1t's agents as current. A workspace's runner settings send g1t's own work (checks, merge checks and the merge queue) to self-hosted runners, and the audit log now says "g1t's work on self-hosted runners" where it said "Agents on self-hosted runners"; the fields keep their names. The runner errors script names the sandboxes that run checks, workflow jobs and merge-queue builds, the reserved name waitlist is the retired waitlist's, and billing's context source is kept only so search embeddings noted before the context service was retired are still charged and closed. Checked: cargo test for g1t-contracts, g1t-billing (243) and g1t-actions (107), the wasm builds of billing and actions, and the ops scripts' tests. | 2 | // What are the runner's Durable Object errors? The sandboxes that run |
| 3 | // checks, workflow jobs and merge-queue builds are Durable Objects of the | |
| Merge remote-tracking branch 'origin/main' into workspace-chat | 4 | // g1t-runner Worker (services/runner: AttemptSandbox, Sandbox2Core, |
| 5 | // Sandbox4Core). This asks Cloudflare, over the last N days: | |
| 6 | // | |
| 7 | // 1. GraphQL Analytics (durableObjectsInvocationsAdaptiveGroups): requests | |
| 8 | // and errors by namespace and status, and by day and status. | |
| 9 | // 2. Workers Observability (the telemetry query API): the runner's failed | |
| 10 | // invocations by class, event type (alarm, rpc, fetch) and outcome; the | |
| 11 | // exceptions they threw, by message; the runner's own error-level logs | |
| 12 | // (`sandbox stop not reported`, `sandbox alarm failed`, `sandbox not | |
| 13 | // started`, `sandbox container error`), by message; and a few recent | |
| 14 | // failed invocations in full. | |
| 15 | // | |
| 16 | // Each message is put in a bucket (`classify`): a deploy resetting the | |
| 17 | // object, no container free, a container that exited, and so on, so what is | |
| 18 | // expected and what is a bug can be told apart at a glance. | |
| 19 | // | |
| 20 | // Read-only: GraphQL queries, one Durable Objects listing for names, and | |
| 21 | // telemetry queries. Never prints the token. | |
| 22 | // | |
| 23 | // CLOUDFLARE_API_TOKEN=<token> node scripts/ops/runner-errors.mjs [--days 7] [--json] | |
| 24 | // | |
| 25 | // The token needs Account Analytics: Read (GraphQL) and Workers Observability: | |
| 26 | // Read (logs); Workers Scripts: Read adds namespace names. A part the token | |
| 27 | // cannot read is a note in the report, never the end of it. | |
| 28 | ||
| 29 | import { ACCOUNT_ID, cloudflareAuth } from "../deploy/cloudflare.mjs"; | |
| 30 | ||
| 31 | const API = "https://api.cloudflare.com/client/v4"; | |
| 32 | ||
| 33 | const HELP = `node scripts/ops/runner-errors.mjs [--days N] [--script NAME] [--samples N] [--json] [--keys] [--account <id>] | |
| 34 | ||
| 35 | The runner's Durable Object errors: requests and errors by namespace and | |
| 36 | status (GraphQL Analytics), and what failed and why (Workers Observability). | |
| 37 | ||
| 38 | --days N how far back, in days (default 7, at most 31) | |
| 39 | --script NAME the Worker (default g1t-runner) | |
| 40 | --samples N recent failed invocations shown in full (default 10) | |
| 41 | --json one JSON object, snake_case keys | |
| 42 | --keys also list the telemetry keys the runner's events have | |
| 43 | --account <id> the Cloudflare account (default CLOUDFLARE_ACCOUNT_ID, else g1t's) | |
| 44 | --help this text | |
| 45 | ||
| 46 | Needs CLOUDFLARE_API_TOKEN (Account Analytics: Read, Workers Observability: | |
| 47 | Read), or CLOUDFLARE_API_KEY with CLOUDFLARE_EMAIL. Exits 1 when nothing | |
| 48 | could be read.`; | |
| 49 | ||
| 50 | /** The command line. */ | |
| 51 | export function parseArgs(argv, env = process.env) { | |
| 52 | const option = (name) => { | |
| 53 | const at = argv.indexOf(name); | |
| 54 | return at >= 0 && argv[at + 1] && !argv[at + 1].startsWith("--") ? argv[at + 1] : null; | |
| 55 | }; | |
| 56 | const days = Number(option("--days") ?? NaN); | |
| 57 | const samples = Number(option("--samples") ?? NaN); | |
| 58 | return { | |
| 59 | help: argv.includes("--help") || argv.includes("-h"), | |
| 60 | json: argv.includes("--json"), | |
| 61 | keys: argv.includes("--keys"), | |
| 62 | days: Number.isFinite(days) && days > 0 ? Math.min(31, days) : 7, | |
| 63 | samples: Number.isFinite(samples) && samples >= 0 ? Math.min(100, Math.floor(samples)) : 10, | |
| 64 | script: option("--script") || "g1t-runner", | |
| 65 | account: option("--account") || env.CLOUDFLARE_ACCOUNT_ID || ACCOUNT_ID, | |
| 66 | }; | |
| 67 | } | |
| 68 | ||
| 69 | /** The window: the last `days` days up to `now`. */ | |
| 70 | export function windowOf(now, days) { | |
| 71 | return { start: new Date(now.getTime() - days * 86_400_000).toISOString(), end: now.toISOString() }; | |
| 72 | } | |
| 73 | ||
| 74 | /** | |
| 75 | * The GraphQL queries, each with variants tried in order when Cloudflare | |
| 76 | * refuses a field. Rows come back under `rows`. | |
| 77 | */ | |
| 78 | export const DO_QUERIES = [ | |
| 79 | { | |
| 80 | key: "by_namespace", | |
| 81 | label: "Durable Object requests by namespace and status", | |
| 82 | variants: [ | |
| 83 | { sum: ["requests", "errors", "wallTime"], dims: ["namespaceId", "status"] }, | |
| 84 | { sum: ["requests", "errors"], dims: ["namespaceId", "status"] }, | |
| 85 | { sum: ["requests"], dims: ["namespaceId", "status"] }, | |
| 86 | { sum: ["requests", "errors"], dims: ["namespaceId"] }, | |
| 87 | ], | |
| 88 | }, | |
| 89 | { | |
| 90 | key: "by_day", | |
| 91 | label: "Durable Object requests by day and status", | |
| 92 | variants: [ | |
| 93 | { sum: ["requests", "errors"], dims: ["date", "status"] }, | |
| 94 | { sum: ["requests"], dims: ["date", "status"] }, | |
| 95 | ], | |
| 96 | }, | |
| 97 | ]; | |
| 98 | ||
| 99 | /** One DO invocations query for one script, for one variant. */ | |
| 100 | export function doQuery(variant) { | |
| 101 | return `query RunnerErrors($accountTag: String!, $start: Time!, $end: Time!, $script: String!) { | |
| 102 | viewer { | |
| 103 | accounts(filter: { accountTag: $accountTag }) { | |
| 104 | rows: durableObjectsInvocationsAdaptiveGroups(limit: 10000, filter: { datetime_geq: $start, datetime_leq: $end, scriptName: $script }) { | |
| 105 | sum { ${variant.sum.join(" ")} } | |
| 106 | dimensions { ${variant.dims.join(" ")} } | |
| 107 | } | |
| 108 | } | |
| 109 | } | |
| 110 | }`; | |
| 111 | } | |
| 112 | ||
| 113 | const snake = (name) => name.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); | |
| 114 | ||
| 115 | /** | |
| 116 | * A GraphQL answer as rows: one per dimension combination, the first | |
| 117 | * dimension named through `names` (namespace ids to names), with each sum | |
| 118 | * and the error rate, largest first by requests. Throws with Cloudflare's | |
| 119 | * message when the answer has errors. | |
| 120 | */ | |
| 121 | export function parseDoGroups(body, variant, names = {}) { | |
| 122 | if (body?.errors?.length) throw new Error(body.errors.map((error) => error.message).join("; ").slice(0, 400)); | |
| 123 | const groups = body?.data?.viewer?.accounts?.[0]?.rows; | |
| 124 | if (!Array.isArray(groups)) throw new Error("no rows in the answer"); | |
| 125 | const rows = groups.map((group) => { | |
| 126 | const row = {}; | |
| 127 | variant.dims.forEach((dim, at) => { | |
| 128 | const value = group.dimensions?.[dim]; | |
| 129 | const text = value == null || value === "" ? "(none)" : String(value); | |
| 130 | row[snake(dim)] = at === 0 && names[text] ? names[text] : text; | |
| 131 | }); | |
| 132 | for (const field of variant.sum) row[snake(field)] = Number(group.sum?.[field] ?? 0); | |
| 133 | return row; | |
| 134 | }); | |
| 135 | const sortKey = variant.dims[0] === "date" ? null : "requests"; | |
| 136 | rows.sort((a, b) => (sortKey ? b.requests - a.requests : 0) || String(a[snake(variant.dims[0])]).localeCompare(String(b[snake(variant.dims[0])]))); | |
| 137 | const totals = Object.fromEntries(variant.sum.map((field) => [snake(field), rows.reduce((sum, row) => sum + row[snake(field)], 0)])); | |
| 138 | return { dims: variant.dims.map(snake), metrics: variant.sum.map(snake), rows, totals }; | |
| 139 | } | |
| 140 | ||
| 141 | /** | |
| 142 | * Requests and errors per status, summed over namespaces: which statuses | |
| 143 | * the errors are (`scriptThrewException`, `internalError`, | |
| 144 | * `clientDisconnected`, `exceededResources`, ...). | |
| 145 | */ | |
| 146 | export function byStatus(parsed) { | |
| 147 | if (!parsed.dims.includes("status")) return []; | |
| 148 | const out = new Map(); | |
| 149 | for (const row of parsed.rows) { | |
| 150 | const entry = out.get(row.status) ?? { status: row.status, requests: 0, errors: 0 }; | |
| 151 | entry.requests += row.requests ?? 0; | |
| 152 | entry.errors += row.errors ?? 0; | |
| 153 | out.set(row.status, entry); | |
| 154 | } | |
| 155 | return [...out.values()].sort((a, b) => b.requests - a.requests); | |
| 156 | } | |
| 157 | ||
| 158 | /** The telemetry keys the questions below group by and filter on. */ | |
| 159 | export const KEYS = { | |
| 160 | outcome: "$workers.outcome", | |
| 161 | eventType: "$workers.eventType", | |
| 162 | entrypoint: "$workers.entrypoint", | |
| 163 | error: "$metadata.error", | |
| 164 | message: "$metadata.message", | |
| 165 | level: "$metadata.level", | |
| 166 | }; | |
| 167 | ||
| 168 | /** Which key names the Worker: tried in order, the next when one finds nothing. */ | |
| 169 | export const SERVICE_KEYS = ["$workers.scriptName", "$metadata.service"]; | |
| 170 | ||
| 171 | const filter = (key, operation, value) => (value === undefined ? { key, operation, type: "string" } : { key, operation, type: "string", value }); | |
| 172 | ||
| 173 | /** | |
| 174 | * The telemetry questions: each a body for `POST .../workers/observability/ | |
| 175 | * telemetry/query`, for the Worker named under `serviceKey`. | |
| 176 | */ | |
| 177 | export function telemetryQuestions({ script, from, to, samples, serviceKey = SERVICE_KEYS[0] }) { | |
| 178 | const service = filter(serviceKey, "eq", script); | |
| 179 | const failed = filter(KEYS.outcome, "neq", "ok"); | |
| 180 | const base = { timeframe: { from, to }, limit: 100 }; | |
| 181 | const count = [{ operator: "count", alias: "events" }]; | |
| 182 | const group = (...keys) => keys.map((value) => ({ type: "string", value })); | |
| 183 | return [ | |
| 184 | { | |
| 185 | key: "failed_invocations", | |
| 186 | label: "Failed invocations by class, event and outcome", | |
| 187 | body: { | |
| 188 | ...base, | |
| 189 | queryId: "g1t-runner-failed-invocations", | |
| 190 | view: "calculations", | |
| 191 | parameters: { datasets: ["cloudflare-workers"], filters: [service, failed], calculations: count, groupBys: group(KEYS.entrypoint, KEYS.eventType, KEYS.outcome) }, | |
| 192 | }, | |
| 193 | }, | |
| 194 | { | |
| 195 | key: "exceptions", | |
| 196 | label: "Exceptions by message", | |
| 197 | body: { | |
| 198 | ...base, | |
| 199 | queryId: "g1t-runner-exceptions", | |
| 200 | view: "calculations", | |
| 201 | parameters: { datasets: ["cloudflare-workers"], filters: [service, filter(KEYS.error, "exists")], calculations: count, groupBys: group(KEYS.error) }, | |
| 202 | }, | |
| 203 | }, | |
| 204 | { | |
| 205 | key: "error_logs", | |
| 206 | label: "The runner's error-level logs by message", | |
| 207 | body: { | |
| 208 | ...base, | |
| 209 | queryId: "g1t-runner-error-logs", | |
| 210 | view: "calculations", | |
| 211 | parameters: { datasets: ["cloudflare-workers"], filters: [service, filter(KEYS.level, "eq", "error")], calculations: count, groupBys: group(KEYS.message) }, | |
| 212 | }, | |
| 213 | }, | |
| 214 | { | |
| 215 | key: "samples", | |
| 216 | label: "Recent failed invocations", | |
| 217 | body: { | |
| 218 | ...base, | |
| 219 | limit: Math.max(1, samples), | |
| 220 | queryId: "g1t-runner-failed-samples", | |
| 221 | view: "events", | |
| 222 | parameters: { datasets: ["cloudflare-workers"], filters: [service, failed] }, | |
| 223 | }, | |
| 224 | }, | |
| 225 | ]; | |
| 226 | } | |
| 227 | ||
| 228 | /** | |
| 229 | * A telemetry calculations answer as rows: each group's key values and its | |
| 230 | * count, largest first. Throws with Cloudflare's message when it failed. | |
| 231 | */ | |
| 232 | export function parseCalculations(body) { | |
| 233 | if (body?.success === false || body?.errors?.length) throw new Error(messagesOf(body)); | |
| 234 | const calculation = body?.result?.calculations?.[0]; | |
| 235 | if (!calculation) throw new Error("no calculations in the answer"); | |
| 236 | const rows = (calculation.aggregates ?? []).map((aggregate) => { | |
| 237 | const row = {}; | |
| 238 | for (const group of aggregate.groups ?? []) row[group.key] = group.value == null || group.value === "" ? "(none)" : String(group.value); | |
| 239 | row.count = Number(aggregate.value ?? aggregate.count ?? 0); | |
| 240 | return row; | |
| 241 | }); | |
| 242 | return rows.sort((a, b) => b.count - a.count); | |
| 243 | } | |
| 244 | ||
| 245 | /** A telemetry events answer as plain records: when, which class, event, outcome, and what went wrong. */ | |
| 246 | export function parseEvents(body) { | |
| 247 | if (body?.success === false || body?.errors?.length) throw new Error(messagesOf(body)); | |
| 248 | const events = body?.result?.events?.events ?? body?.result?.events ?? []; | |
| 249 | if (!Array.isArray(events)) throw new Error("no events in the answer"); | |
| 250 | return events.map((event) => { | |
| 251 | const workers = event.$workers ?? {}; | |
| 252 | const metadata = event.$metadata ?? {}; | |
| 253 | const at = event.timestamp ?? metadata.startTime ?? workers.timestamp; | |
| 254 | return { | |
| 255 | at: typeof at === "number" ? new Date(at).toISOString() : (at ?? null), | |
| 256 | entrypoint: workers.entrypoint ?? null, | |
| 257 | event_type: workers.eventType ?? null, | |
| 258 | outcome: workers.outcome ?? null, | |
| 259 | object: typeof workers.durableObjectId === "string" ? workers.durableObjectId.slice(0, 12) : null, | |
| 260 | error: metadata.error ?? null, | |
| 261 | message: metadata.message ?? (typeof event.source === "string" ? event.source : (event.source?.message ?? null)), | |
| 262 | }; | |
| 263 | }); | |
| 264 | } | |
| 265 | ||
| 266 | function messagesOf(body) { | |
| 267 | const errors = body?.errors ?? []; | |
| 268 | const text = errors.map((error) => error.message ?? JSON.stringify(error)).join("; "); | |
| 269 | return (text || `request failed${body?.status ? ` with ${body.status}` : ""}`).slice(0, 400); | |
| 270 | } | |
| 271 | ||
| 272 | /** | |
| 273 | * What a failure most likely is, from its message or outcome: a bucket and | |
| 274 | * whether it is expected. Order matters: the first match wins. | |
| 275 | */ | |
| 276 | export const BUCKETS = [ | |
| 277 | { bucket: "deploy_reset", expected: true, why: "a runner deploy reset the object mid-invocation", test: /code (was|has been) updated|reset because its code|new version of the (script|worker)|durable object reset/i }, | |
| 278 | { bucket: "stop_not_reported", expected: false, why: "onStop could not tell a service the sandbox stopped (now logged, not thrown)", test: /sandbox stop not reported/i }, | |
| 279 | { bucket: "alarm_failed", expected: false, why: "the sandbox's alarm threw (retried by Cloudflare)", test: /sandbox alarm failed/i }, | |
| 280 | { bucket: "no_capacity", expected: true, why: "no container instance free (max_instances, or provisioning)", test: /no container instance|max(imum)? concurrent instance|too many containers per second/i }, | |
| 281 | { bucket: "not_started", expected: false, why: "a sandbox could not start (guardrails unreadable, or the container would not start)", test: /sandbox not started|could not read this project's guardrails|did not start after|failed to start container/i }, | |
| 282 | { bucket: "container_exited", expected: true, why: "the container exited or was stopped (a finished run, a time cap, a stop)", test: /container exited|runtime signalled|exited before we could determine|crashed while checking for ports|exit code/i }, | |
| 283 | { bucket: "connection_lost", expected: false, why: "the connection to the container was lost", test: /network connection lost|disconnected/i }, | |
| 284 | { bucket: "storage", expected: false, why: "Durable Object storage failed or was overloaded", test: /storage|sqlite|overloaded/i }, | |
| 285 | { bucket: "limits", expected: false, why: "a CPU, memory or subrequest limit", test: /exceeded|too many subrequests|memory limit/i }, | |
| 286 | { bucket: "container_error", expected: false, why: "the containers library reported an error", test: /sandbox container error|container error/i }, | |
| 287 | ]; | |
| 288 | ||
| 289 | /** The bucket of one failure's message (or, failing that, its outcome). */ | |
| 290 | export function classify(message, outcome = null) { | |
| 291 | const text = String(message ?? ""); | |
| 292 | for (const bucket of BUCKETS) if (text && bucket.test.test(text)) return { bucket: bucket.bucket, expected: bucket.expected, why: bucket.why }; | |
| 293 | if (/canceled|cancelled|clientdisconnected|responsestreamdisconnected/i.test(String(outcome ?? ""))) { | |
| 294 | return { bucket: "caller_gone", expected: true, why: "the caller went away before the object answered" }; | |
| 295 | } | |
| 296 | return { bucket: "other", expected: false, why: "not recognised: read the message" }; | |
| 297 | } | |
| 298 | ||
| 299 | /** Rows of messages with counts, summed into buckets, largest first. */ | |
| 300 | export function bucketsOf(rows, messageKey) { | |
| 301 | const out = new Map(); | |
| 302 | for (const row of rows) { | |
| 303 | const found = classify(row[messageKey], row[KEYS.outcome]); | |
| 304 | const entry = out.get(found.bucket) ?? { ...found, count: 0 }; | |
| 305 | entry.count += row.count; | |
| 306 | out.set(found.bucket, entry); | |
| 307 | } | |
| 308 | return [...out.values()].sort((a, b) => b.count - a.count); | |
| 309 | } | |
| 310 | ||
| 311 | const number = (value) => (Number.isInteger(value) ? value.toLocaleString("en-US") : value.toLocaleString("en-US", { maximumFractionDigits: 2 })); | |
| 312 | const percent = (part, whole) => (whole > 0 ? `${((100 * part) / whole).toFixed(1)}%` : "-"); | |
| 313 | ||
| 314 | function table(rows, columns) { | |
| 315 | if (!rows.length) return [" (nothing in this window)"]; | |
| 316 | const cells = [columns.map((c) => c.title), ...rows.map((row) => columns.map((c) => c.value(row)))]; | |
| 317 | const widths = columns.map((_, at) => Math.max(...cells.map((row) => String(row[at]).length))); | |
| 318 | return cells.map((row) => " " + row.map((cell, at) => (columns[at].right ? String(cell).padStart(widths[at]) : String(cell).padEnd(widths[at]))).join(" ")); | |
| 319 | } | |
| 320 | ||
| 321 | /** The report as plain text. */ | |
| 322 | export function format(report, top = 25) { | |
| 323 | const lines = [`Durable Object errors of ${report.script} on account ${report.account}, ${report.start} to ${report.end}`]; | |
| 324 | for (const query of report.analytics) { | |
| 325 | lines.push("", query.label); | |
| 326 | if (query.error) { | |
| 327 | lines.push(` could not read: ${query.error}`); | |
| 328 | continue; | |
| 329 | } | |
| 330 | const columns = [ | |
| 331 | ...query.dims.map((dim) => ({ title: dim, value: (row) => row[dim] })), | |
| 332 | ...query.metrics.map((metric) => ({ title: metric, right: true, value: (row) => number(row[metric]) })), | |
| 333 | ]; | |
| 334 | if (query.metrics.includes("errors")) columns.push({ title: "error_rate", right: true, value: (row) => percent(row.errors, row.requests) }); | |
| 335 | lines.push(...table(query.rows.slice(0, top), columns)); | |
| 336 | lines.push(` total: ${Object.entries(query.totals).map(([metric, value]) => `${metric} ${number(value)}`).join(", ")}`); | |
| 337 | if (query.by_status?.length) { | |
| 338 | lines.push(" by status:"); | |
| 339 | lines.push(...table(query.by_status, [ | |
| 340 | { title: "status", value: (row) => row.status }, | |
| 341 | { title: "requests", right: true, value: (row) => number(row.requests) }, | |
| 342 | { title: "errors", right: true, value: (row) => number(row.errors) }, | |
| 343 | ]).map((line) => ` ${line}`)); | |
| 344 | } | |
| 345 | } | |
| 346 | for (const question of report.telemetry) { | |
| 347 | lines.push("", question.label); | |
| 348 | if (question.error) { | |
| 349 | lines.push(` could not read: ${question.error}`); | |
| 350 | continue; | |
| 351 | } | |
| 352 | if (question.key === "samples") { | |
| 353 | if (!question.rows.length) lines.push(" (none)"); | |
| 354 | for (const row of question.rows) { | |
| 355 | lines.push(` ${row.at ?? "?"} ${row.entrypoint ?? "?"} ${row.event_type ?? "?"} ${row.outcome ?? "?"}${row.object ? ` ${row.object}` : ""}`); | |
| 356 | if (row.error || row.message) lines.push(` ${String(row.error ?? row.message).slice(0, 300)}`); | |
| 357 | } | |
| 358 | continue; | |
| 359 | } | |
| 360 | const keys = Object.keys(question.rows[0] ?? {}).filter((key) => key !== "count"); | |
| 361 | lines.push(...table(question.rows.slice(0, top), [ | |
| 362 | ...keys.map((key) => ({ title: key, value: (row) => String(row[key] ?? "").slice(0, 120) })), | |
| 363 | { title: "count", right: true, value: (row) => number(row.count) }, | |
| 364 | ])); | |
| 365 | if (question.buckets?.length) { | |
| 366 | lines.push(" most likely:"); | |
| 367 | for (const bucket of question.buckets) lines.push(` ${String(number(bucket.count)).padStart(6)} ${bucket.bucket}${bucket.expected ? " (expected)" : ""}: ${bucket.why}`); | |
| 368 | } | |
| 369 | } | |
| 370 | if (report.keys) lines.push("", "Telemetry keys:", ...report.keys.map((key) => ` ${key}`)); | |
| 371 | if (report.notes.length) lines.push("", "Notes:", ...report.notes.map((note) => ` ${note}`)); | |
| 372 | return lines.join("\n"); | |
| 373 | } | |
| 374 | ||
| 375 | async function post(auth, path, body) { | |
| 376 | const response = await fetch(`${API}${path}`, { | |
| 377 | method: "POST", | |
| 378 | headers: { ...auth, "content-type": "application/json", "user-agent": "g1t-ops" }, | |
| 379 | body: JSON.stringify(body), | |
| 380 | }); | |
| 381 | const answer = await response.json().catch(() => ({ success: false, errors: [{ message: `HTTP ${response.status}, not JSON` }] })); | |
| 382 | if (!response.ok && !answer.errors?.length) answer.errors = [{ message: `HTTP ${response.status}` }]; | |
| 383 | return answer; | |
| 384 | } | |
| 385 | ||
| 386 | async function durableNames(auth, account) { | |
| 387 | const out = {}; | |
| 388 | try { | |
| 389 | for (let page = 1; page <= 10; page++) { | |
| 390 | const response = await fetch(`${API}/accounts/${account}/workers/durable_objects/namespaces?per_page=100&page=${page}`, { headers: { ...auth, "user-agent": "g1t-ops" } }); | |
| 391 | const body = await response.json(); | |
| 392 | if (!response.ok || !Array.isArray(body.result)) break; | |
| 393 | for (const item of body.result) if (item.id) out[item.id] = item.name ?? item.id; | |
| 394 | if (body.result.length < 100) break; | |
| 395 | } | |
| 396 | } catch { | |
| 397 | // Ids stand in for names. | |
| 398 | } | |
| 399 | return out; | |
| 400 | } | |
| 401 | ||
| 402 | async function analytics(auth, options, window, names) { | |
| 403 | return Promise.all( | |
| 404 | DO_QUERIES.map(async (query) => { | |
| 405 | let error = null; | |
| 406 | for (const variant of query.variants) { | |
| 407 | try { | |
| 408 | const body = await post(auth, "/graphql", { query: doQuery(variant), variables: { accountTag: options.account, start: window.start, end: window.end, script: options.script } }); | |
| 409 | const parsed = parseDoGroups(body, variant, names); | |
| 410 | return { key: query.key, label: query.label, ...parsed, by_status: query.key === "by_namespace" ? byStatus(parsed) : undefined }; | |
| 411 | } catch (thrown) { | |
| 412 | error ??= String(thrown.message ?? thrown); | |
| 413 | } | |
| 414 | } | |
| 415 | return { key: query.key, label: query.label, error }; | |
| 416 | }), | |
| 417 | ); | |
| 418 | } | |
| 419 | ||
| 420 | async function telemetry(auth, options, window) { | |
| 421 | const path = `/accounts/${options.account}/workers/observability/telemetry/query`; | |
| 422 | const from = Date.parse(window.start); | |
| 423 | const to = Date.parse(window.end); | |
| 424 | let results = null; | |
| 425 | for (const serviceKey of SERVICE_KEYS) { | |
| 426 | results = await Promise.all( | |
| 427 | telemetryQuestions({ script: options.script, from, to, samples: options.samples, serviceKey }).map(async (question) => { | |
| 428 | try { | |
| 429 | const body = await post(auth, path, question.body); | |
| 430 | if (question.key === "samples") return { key: question.key, label: question.label, rows: parseEvents(body) }; | |
| 431 | const rows = parseCalculations(body); | |
| 432 | const messageKey = question.key === "exceptions" ? KEYS.error : question.key === "error_logs" ? KEYS.message : null; | |
| 433 | return { key: question.key, label: question.label, rows, buckets: messageKey ? bucketsOf(rows, messageKey) : undefined }; | |
| 434 | } catch (error) { | |
| 435 | return { key: question.key, label: question.label, error: String(error.message ?? error) }; | |
| 436 | } | |
| 437 | }), | |
| 438 | ); | |
| 439 | // Another key names the Worker when this one found nothing at all. | |
| 440 | if (results.some((result) => result.rows?.length)) return { serviceKey, results }; | |
| 441 | } | |
| 442 | return { serviceKey: SERVICE_KEYS.at(-1), results }; | |
| 443 | } | |
| 444 | ||
| 445 | async function telemetryKeys(auth, options, window) { | |
| 446 | const body = await post(auth, `/accounts/${options.account}/workers/observability/telemetry/keys`, { | |
| 447 | timeframe: { from: Date.parse(window.start), to: Date.parse(window.end) }, | |
| 448 | datasets: ["cloudflare-workers"], | |
| 449 | filters: [filter(SERVICE_KEYS[0], "eq", options.script)], | |
| 450 | limit: 500, | |
| 451 | }); | |
| 452 | if (body?.success === false || body?.errors?.length) throw new Error(messagesOf(body)); | |
| 453 | return (body.result ?? []).map((key) => (typeof key === "string" ? key : `${key.key} (${key.type})`)).sort(); | |
| 454 | } | |
| 455 | ||
| 456 | async function main() { | |
| 457 | const options = parseArgs(process.argv.slice(2)); | |
| 458 | if (options.help) { | |
| 459 | console.log(HELP); | |
| 460 | return 0; | |
| 461 | } | |
| 462 | const auth = cloudflareAuth(); | |
| 463 | if (!auth) { | |
| 464 | console.error(`Set CLOUDFLARE_API_TOKEN to a token with Account Analytics: Read and Workers Observability: Read on account ${options.account}.`); | |
| 465 | return 2; | |
| 466 | } | |
| 467 | const window = windowOf(new Date(), options.days); | |
| 468 | const names = await durableNames(auth, options.account); | |
| 469 | const [graph, logs, keys] = await Promise.all([ | |
| 470 | analytics(auth, options, window, names), | |
| 471 | telemetry(auth, options, window), | |
| 472 | options.keys ? telemetryKeys(auth, options, window).catch((error) => [`could not list keys: ${error.message}`]) : Promise.resolve(null), | |
| 473 | ]); | |
| 474 | const notes = []; | |
| 475 | if (!Object.keys(names).length) notes.push("Namespace ids are not named: the token cannot read the Durable Objects listing (Workers Scripts: Read)."); | |
| 476 | notes.push(`Telemetry is filtered on ${logs.serviceKey}.`); | |
| 477 | const report = { account: options.account, script: options.script, start: window.start, end: window.end, analytics: graph, telemetry: logs.results, keys, notes }; | |
| 478 | console.log(options.json ? JSON.stringify(report, null, 2) : format(report)); | |
| 479 | const read = [...graph, ...logs.results].some((part) => !part.error); | |
| 480 | return read ? 0 : 1; | |
| 481 | } | |
| 482 | ||
| 483 | if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/ops/runner-errors.mjs")) { | |
| 484 | main().then( | |
| 485 | (code) => process.exit(code), | |
| 486 | (error) => { | |
| 487 | console.error(`runner-errors: ${error.message}`); | |
| 488 | process.exit(1); | |
| 489 | }, | |
| 490 | ); | |
| 491 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.