| 1 | #!/usr/bin/env node |
| 2 | // What are the runner's Durable Object errors? The sandboxes that run agent |
| 3 | // attempts, workflow jobs and merge-queue builds are Durable Objects of the |
| 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 | } |