Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
3 commits
- Code button: the SSH tab shows the address it will have, disabled, with why it is not on yetChase Pierce3c21ca3
- Billing: AI Gateway's analytics are read with the token that can see them, and a gateway that priced nothing is saidChase Pierce9e5a53a
- Artifacts: an hourly view of operations and errors, and SSH must meter its git before it shipsChase Pierce6159630
11 files+197−350/11 viewed
| 1 | 1 | import { Link } from "react-router"; | |
| 2 | 2 | ||
| 3 | − | import { cloneUrl, useAddresses } from "../lib/addresses"; | |
| 3 | + | import { cloneUrl, sshUrl, useAddresses } from "../lib/addresses"; | |
| 4 | 4 | import { AgentSetup } from "./agent-setup"; | |
| 5 | 5 | import { CopyLine } from "./ui"; | |
| 6 | 6 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; | |
| 26 | 26 | </p> | |
| 27 | 27 | </TabsContent> | |
| 28 | 28 | <TabsContent value="ssh"> | |
| 29 | − | <p className="rounded-lg border border-dashed border-line p-3 text-xs text-muted"> | |
| 30 | − | Git over SSH is waiting on inbound TCP on Cloudflare, which g1t has applied for. Use HTTPS for now: it | |
| 31 | − | clones, fetches and pushes the same. Keys you add under Settings → SSH keys will work as soon as SSH is on. | |
| 29 | + | <CopyLine text={sshUrl(addresses, path)} disabled /> | |
| 30 | + | <p className="mt-2 text-xs text-muted"> | |
| 31 | + | Not on yet: git over SSH is waiting on inbound TCP on Cloudflare, which g1t has applied for. Use HTTPS for | |
| 32 | + | now; it clones, fetches and pushes the same. Keys you add under{" "} | |
| 33 | + | <Link to="/settings/keys" className="text-fg underline underline-offset-4"> | |
| 34 | + | SSH keys | |
| 35 | + | </Link>{" "} | |
| 36 | + | will work as soon as SSH is on. | |
| 32 | 37 | </p> | |
| 33 | 38 | </TabsContent> | |
| 34 | 39 | <TabsContent value="agent"> |
| 348 | 348 | export function CopyLine({ | |
| 349 | 349 | text, | |
| 350 | 350 | prompt, | |
| 351 | + | disabled, | |
| 351 | 352 | }: { | |
| 352 | 353 | text: string; | |
| 353 | 354 | prompt?: boolean; | |
| 355 | + | /** Shown, so it is clear what will be there, but not yet usable: dimmed, with no copy. */ | |
| 356 | + | disabled?: boolean; | |
| 354 | 357 | }) { | |
| 355 | 358 | const [copied, setCopied] = useState(false); | |
| 356 | 359 | return ( | |
| 357 | − | <div className="group flex items-center gap-3 rounded-lg border border-line bg-surface py-2 pr-2 pl-3.5 font-mono text-[0.8125rem]"> | |
| 360 | + | <div | |
| 361 | + | aria-disabled={disabled || undefined} | |
| 362 | + | className={`group flex items-center gap-3 rounded-lg border border-line bg-surface py-2 pr-2 pl-3.5 font-mono text-[0.8125rem] ${disabled ? "cursor-not-allowed text-faint select-none" : ""}`} | |
| 363 | + | > | |
| 358 | 364 | {/* Wraps rather than hides: a command or an address is no use half seen. */} | |
| 359 | 365 | <code className="min-w-0 grow whitespace-pre-wrap [overflow-wrap:anywhere]"> | |
| 360 | 366 | {prompt && <span className="mr-2 text-faint select-none">$</span>} | |
| 373 | 379 | <button | |
| 374 | 380 | type="button" | |
| 375 | 381 | aria-label="Copy" | |
| 376 | − | className="shrink-0 rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-fg" | |
| 382 | + | disabled={disabled} | |
| 383 | + | className="shrink-0 rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-fg disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-faint" | |
| 377 | 384 | onClick={() => { | |
| 378 | 385 | void navigator.clipboard.writeText(text); | |
| 379 | 386 | setCopied(true); |
| 54 | 54 | return addressesFrom(useRouteLoaderData("root")); | |
| 55 | 55 | } | |
| 56 | 56 | ||
| 57 | + | /** The SSH clone address of a repository (`git@host:owner/name.git`), from its `owner/name`. */ | |
| 58 | + | export function sshUrl(addresses: Pick<Addresses, "site">, path: string): string { | |
| 59 | + | return `git@${new URL(addresses.site).hostname}:${path.replace(/^\/+/, "")}.git`; | |
| 60 | + | } | |
| 61 | + | ||
| 57 | 62 | /** The HTTPS clone address of a repository, from its `owner/name`. */ | |
| 58 | 63 | export function cloneUrl(addresses: Pick<Addresses, "site">, path: string): string { | |
| 59 | 64 | return `${addresses.site}/${path.replace(/^\/+/, "")}.git`; |
| 1 | 1 | import { identity } from "../../lib/services.server"; | |
| 2 | − | import { Form } from "react-router"; | |
| 2 | + | import { Form, Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/keys"; | |
| 5 | 5 | import { page } from "../../lib/meta"; | |
| 40 | 40 | const { keys } = loaderData; | |
| 41 | 41 | return ( | |
| 42 | 42 | <section id="ssh-keys" className="scroll-mt-20"> | |
| 43 | + | <p className="mb-4 rounded-lg border border-dashed border-line p-3 text-xs text-muted"> | |
| 44 | + | Git over SSH is not on yet: it is waiting on inbound TCP on Cloudflare, which g1t has applied for. Keys you | |
| 45 | + | add now will work as soon as it is. Until then, clone and push over HTTPS with an{" "} | |
| 46 | + | <Link to="/settings/tokens" className="text-fg underline underline-offset-4"> | |
| 47 | + | access token | |
| 48 | + | </Link> | |
| 49 | + | . | |
| 50 | + | </p> | |
| 43 | 51 | <ul className="divide-y divide-line rounded-md border border-line empty:hidden"> | |
| 44 | 52 | {keys.map((key) => ( | |
| 45 | 53 | <li key={key.id} className="flex items-center gap-4 px-4 py-3"> |
| 4 | 4 | //! | |
| 5 | 5 | //! Connections arrive either as raw TCP or wrapped in a WebSocket, which is | |
| 6 | 6 | //! how they reach a Cloudflare Container when tunnelled through a Worker. | |
| 7 | + | //! | |
| 8 | + | //! Not deployed. Before it ships, its git operations must be metered: the | |
| 9 | + | //! bridge reaches the store directly, so `git_http` never counts them (see | |
| 10 | + | //! docs/ARTIFACTS.md, "where the gap came from"). | |
| 7 | 11 | ||
| 8 | 12 | mod api; | |
| 9 | 13 | mod git; |
| 348 | 348 | workspaces, because the 1 TB account limit would stop every push. | |
| 349 | 349 | - **Before agent volume ramps (about 1,000 pull requests a day):** R2 deletion, R3, R4, R5. | |
| 350 | 350 | - **Before a few thousand active workspaces:** R6, R7, R9, R10. | |
| 351 | + | - **Before git over SSH ships:** SSH's git operations are metered (see "where the gap came | |
| 352 | + | from" under R1). Until then `crates/sshd` stays undeployed. | |
| 351 | 353 | - Everything else is resilience and exit planning, ideally done while the product is still invite-only. | |
| 352 | 354 | ||
| 353 | 355 | ## 9. What was built (2026-10-06) | |
| 384 | 386 | node scripts/ops/artifacts-usage.mjs # last 31 days, a table | |
| 385 | 387 | node scripts/ops/artifacts-usage.mjs --days 7 --json > usage.json | |
| 386 | 388 | ARTIFACTS_NAMESPACE=g1t node scripts/ops/artifacts-usage.mjs | |
| 389 | + | node scripts/ops/artifacts-usage.mjs --hours 2026-10-07 # one UTC day, hour by hour | |
| 387 | 390 | ``` | |
| 388 | 391 | ||
| 389 | 392 | It prints, per day, Cloudflare's `pull`, `push`, `create`, `fork` and `delete` events and its | |
| 400 | 403 | lost before they were written. Sandboxes are not it: every sandbox but a backup's clones, | |
| 401 | 404 | fetches and pushes through g1t's git endpoints (see "2026-10-07: where the gap came from"). | |
| 402 | 405 | - Only days after the meters were deployed compare; before that only `git_operations` exists. | |
| 406 | + | - A fix that lands mid-day is judged with `--hours DAY`: Cloudflare's operations and errors | |
| 407 | + | against `git_operations` hour by hour, then the day's errors by message and repository. | |
| 403 | 408 | - Binding calls do appear: Cloudflare's events include `read` and `token_create` actions (and | |
| 404 | 409 | `namespace_*`) besides the five documented ones. If Cloudflare says they are billed, map the | |
| 405 | 410 | `binding.*` meters in `operation_mapping`. | |
| 434 | 439 | read `cache.edge_hit` against `cache.miss` after a day; if the Cache API never hits from a | |
| 435 | 440 | Worker reached only by service bindings, put objects in KV instead. Still to do: caller | |
| 436 | 441 | attribution in the meters. | |
| 437 | − | - 476 client errors on 2026-10-06 are unexplained; the fetch fix below accounts for some (every | |
| 438 | − | failed negotiation was one). | |
| 442 | + | - Client errors (576 on 2026-10-06, 985 on 2026-10-07) are all `read rejected`: a binding | |
| 443 | + | `readFile` for a path that is not a file at that ref (missing, or a directory). Tested on | |
| 444 | + | 2026-10-07: four anonymous views of a missing file on a public repository made four, a real | |
| 445 | + | file none. Nearly all came from crawlers (ClaudeBot, GPTBot) on public `blob/<sha>/…` pages | |
| 446 | + | and pull requests' working copies, hour after hour with no git at all. The site answers 404 | |
| 447 | + | correctly; each is one store read, and a miss is not cached. They are not operations. | |
| 448 | + | `--hours DAY` lists them by message and repository. | |
| 439 | 449 | ||
| 440 | 450 | **2026-10-07: where the gap came from.** Cloudflare counted 581 operations (pull 535, push 39, | |
| 441 | 451 | create 3, fork 4) against g1t's 458 (`git.fetch` 417, `git.receive_pack` 33, ...). The suspicion | |
| 450 | 460 | `git.info_refs`, `git.ls_refs`, `git.fetch` and `git.receive_pack`. Git an agent runs itself | |
| 451 | 461 | in its sandbox has the same remote and no other credential, so it is metered the same way. | |
| 452 | 462 | - `git_access` has no caller that is deployed: only `crates/sshd`, whose `/_internal/ssh/*` | |
| 453 | − | endpoints do not exist yet. When git over SSH ships, its bridge talks to the store directly | |
| 454 | − | and must report what it does (as backups do) or go through `git_http`. | |
| 463 | + | endpoints do not exist yet. **SSH must not ship until its git is metered.** Its bridge | |
| 464 | + | (`crates/sshd/src/git.rs`) talks to the store directly with the handed-out token, so nothing | |
| 465 | + | in `git_http` sees it: every SSH clone, fetch and push would be an operation Cloudflare bills | |
| 466 | + | and g1t never counts. Two ways to close it, either is enough: | |
| 467 | + | 1. Report, as backups do: when a session ends, sshd posts the service, the repo and the bytes | |
| 468 | + | each way to a repos RPC that records `git.info_refs` plus `git.fetch` or | |
| 469 | + | `git.receive_pack` against the repo's store key (`meters::record`, like `backups.rs` | |
| 470 | + | `meter_fetch`). A session that dies before reporting is lost, so count the operation when | |
| 471 | + | `git_access` hands out the token and add only the bytes from the report. | |
| 472 | + | 2. Send the bridge through `git_http` instead of the store, with the user's identity, so SSH | |
| 473 | + | is metered, cached and protected (branch and push protection) like HTTPS. This also closes | |
| 474 | + | M15 for SSH. | |
| 475 | + | ||
| 476 | + | Building this is not small today: the Worker side (`/_internal/ssh/user` and | |
| 477 | + | `/_internal/ssh/access`) does not exist either, so it belongs with shipping SSH. | |
| 455 | 478 | - The one sandbox that reads the store directly is a nightly backup (`backups.rs` | |
| 456 | 479 | `store.handout`): its runner reports the clone with `fetched_bytes`, metered as | |
| 457 | 480 | `internal.git.info_refs` and `internal.git.backup_fetch` (g1t's cost, never a workspace's). |
| 40 | 40 | | Secret on g1t-billing | Permissions | Used for | | |
| 41 | 41 | | --- | --- | --- | | |
| 42 | 42 | | `CLOUDFLARE_BILLING_TOKEN` (optional) | Account: **Billing Read**, Account: **Account Analytics Read**, for the g1t account only | Reading the bill, the Artifacts events and the subscriptions | | |
| 43 | − | | `CLOUDFLARE_USAGE_TOKEN` (exists) | Billing Read, Account Analytics Read, AI Gateway Read | The keeper (settling runs from the gateway's logs); also the bill when `CLOUDFLARE_BILLING_TOKEN` is not set. AI Gateway's analytics are read with the bill's token first and, if that is refused, with this one | | |
| 43 | + | | `CLOUDFLARE_USAGE_TOKEN` (exists) | Billing Read, Account Analytics Read, AI Gateway Read | The keeper (settling runs from the gateway's logs); also the bill when `CLOUDFLARE_BILLING_TOKEN` is not set. AI Gateway's analytics are read with this token first and, if that is refused, with the bill's: Cloudflare answers a token without AI Gateway Read with no rows rather than an error, so the bill's token would read as a gateway that priced nothing | | |
| 44 | 44 | ||
| 45 | 45 | With neither, the daily run reconciles only what g1t counted itself, and | |
| 46 | 46 | the page says the bill cannot be read. Nothing fails. To set the scoped one: | |
| 178 | 178 | | --- | --- | --- | | |
| 179 | 179 | | Count | g1t's count and Cloudflare's differ by more than the mapping's `drift_percent` (10%) | Find out what Cloudflare counts: compare its events with `own_counts` `artifacts_*` and `cost_operations`. If it counts more (binding reads, `ls-refs`), either change repos' `operation_mapping` so customers are charged for what Cloudflare counts, or leave it and let the per-unit cost rise (below). | | |
| 180 | 180 | | Cost | Cloudflare charged more than `drift_percent` away from the price book's cost of the same usage, with at least `min_daily_cost` | A price is stale: check the proposals. | | |
| 181 | − | | Cost, on `models` | What AI Gateway priced g1t's own provider traffic at over the 7 days, against the ledger's model cost for the same days (billed to g1t: comped, free and trial use included, a workspace's own provider not), more than the `ai_gateway_requests` mapping's `drift_percent` (10%) apart, with at least `min_daily_cost`. Compared once the gateway has been read; then a ledger with none of it is drift too | The gateway higher: model calls g1t paid for and charged no one: runs not settled yet (they catch up within the hour), runs with no session, a run started without a billing ticket, or something else on g1t's gateway. The ledger higher: runs that reached a provider without the gateway. The detail adds why the gateway's own figure may be off: prompt-cache read and write tokens (the gateway prices them at its rates for cache tokens, which can lag the provider's; check against the provider's invoice), requests Cloudflare billed itself (unified billing: on Cloudflare's bill, not a provider's), and models with no price. Days are UTC by when a request ran (gateway) and when a charge was entered (ledger), so a run across midnight shifts a little between days; the 7-day sum absorbs it. | | |
| 181 | + | | Cost, on `models` | What AI Gateway priced g1t's own provider traffic at over the 7 days, against the ledger's model cost for the same days (billed to g1t: comped, free and trial use included, a workspace's own provider not), more than the `ai_gateway_requests` mapping's `drift_percent` (10%) apart, with at least `min_daily_cost`. A ledger with none of the gateway's cost is drift too, and so is a gateway that priced nothing against a ledger with at least `min_daily_cost` of model cost (no percentage): that is not agreement, it is a token that cannot see AI Gateway, or calls that went around it | The gateway higher: model calls g1t paid for and charged no one: runs not settled yet (they catch up within the hour), runs with no session, a run started without a billing ticket, or something else on g1t's gateway. The ledger higher: runs that reached a provider without the gateway. The detail adds why the gateway's own figure may be off: prompt-cache read and write tokens (the gateway prices them at its rates for cache tokens, which can lag the provider's; check against the provider's invoice), requests Cloudflare billed itself (unified billing: on Cloudflare's bill, not a provider's), and models with no price. Days are UTC by when a request ran (gateway) and when a charge was entered (ledger), so a run across midnight shifts a little between days; the 7-day sum absorbs it. | | |
| 182 | 182 | | Unpriced | Over the 7 days, a model in AI Gateway's analytics with tokens and $0 cost, or runs settled with `runs.gateway_note` (the gateway could not price all of a run) | The gateway has no price for a model g1t runs: add it in the gateway (custom cost) or route away from it. Until then those runs are charged no less than the sandbox reported (Claude Code's own price table), never $0 silently. | | |
| 183 | 183 | | Leak | Cost of at least `min_daily_cost` and nothing charged for it (never for `platform`), or a meter in `unmapped` | Map the meter (below), or decide it is overhead (`platform`). | | |
| 184 | 184 |
| 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 | }); |
| 581 | 581 | // the total the ledger's model cost is checked against (`margin`). | |
| 582 | 582 | if !keeper.gateway().is_empty() { | |
| 583 | 583 | match keeper | |
| 584 | − | .graphql_either(gateway_variables(keeper.account(), keeper.gateway(), &since, &until)) | |
| 584 | + | .gateway_graphql(gateway_variables(keeper.account(), keeper.gateway(), &since, &until)) | |
| 585 | 585 | .await | |
| 586 | 586 | .map_err(|e| e.to_string()) | |
| 587 | 587 | .and_then(|body| lines_from_gateway(&body)) |
| 115 | 115 | &self.gateway | |
| 116 | 116 | } | |
| 117 | 117 | ||
| 118 | − | /// A GraphQL query with the bill's token, and on failure with the | |
| 119 | − | /// keeper's (AI Gateway Read), when that is a different token. | |
| 120 | − | pub(crate) async fn graphql_either(&self, body: Value) -> Result<Value> { | |
| 121 | − | match self.graphql(body.clone()).await { | |
| 118 | + | /// A GraphQL query over AI Gateway's analytics: with the keeper's token | |
| 119 | + | /// (AI Gateway Read) first, and on failure with the bill's. Not the | |
| 120 | + | /// other way round: Cloudflare answers a token that cannot see AI | |
| 121 | + | /// Gateway with no rows, not an error, so the bill's token would read | |
| 122 | + | /// as a gateway that priced nothing. | |
| 123 | + | pub(crate) async fn gateway_graphql(&self, body: Value) -> Result<Value> { | |
| 124 | + | let Some(token) = &self.token else { | |
| 125 | + | return self.graphql(body).await; | |
| 126 | + | }; | |
| 127 | + | match send_with(token, Method::Post, "https://api.cloudflare.com/client/v4/graphql", Some(body.clone())).await { | |
| 122 | 128 | Ok(answer) => Ok(answer), | |
| 123 | − | Err(error) => match &self.token { | |
| 124 | − | Some(token) if Some(token) != self.billing_token.as_ref() => { | |
| 125 | − | send_with(token, Method::Post, "https://api.cloudflare.com/client/v4/graphql", Some(body)).await | |
| 126 | − | } | |
| 129 | + | Err(error) => match &self.billing_token { | |
| 130 | + | Some(billing) if billing != token => self.graphql(body).await, | |
| 127 | 131 | _ => Err(error), | |
| 128 | 132 | }, | |
| 129 | 133 | } |
| 437 | 437 | out.push(Drift { bucket: bucket.into(), kind: DriftKind::Cost, ours: own_cost, cloudflare: cf_cost, delta_percent: delta }); | |
| 438 | 438 | } | |
| 439 | 439 | } | |
| 440 | + | // The ledger has model cost and the gateway priced none of it: a token | |
| 441 | + | // that cannot see AI Gateway reads as no rows, never an error, so this | |
| 442 | + | // is not agreement. Said, rather than left as no row at all. | |
| 443 | + | if NOT_CLOUDFLARE.contains(&bucket) && cf_cost <= 0.0 && own_cost >= min_cost_micros as f64 && own_cost > 0.0 { | |
| 444 | + | out.push(Drift { bucket: bucket.into(), kind: DriftKind::Cost, ours: own_cost, cloudflare: 0.0, delta_percent: None }); | |
| 445 | + | } | |
| 440 | 446 | if !overhead && cf_cost >= min_cost_micros as f64 && value <= 0.0 { | |
| 441 | 447 | out.push(Drift { bucket: bucket.into(), kind: DriftKind::Leak, ours: value, cloudflare: cf_cost, delta_percent: None }); | |
| 442 | 448 | } | |
| 473 | 479 | ||
| 474 | 480 | /// The models drift's detail: the gateway's total against the ledger's. | |
| 475 | 481 | pub(crate) fn models_detail(drift: &Drift, caveats: &costs::GatewayCaveats) -> String { | |
| 482 | + | if drift.cloudflare <= 0.0 { | |
| 483 | + | return format!( | |
| 484 | + | "Models: the ledger's model cost is {} over the last {DRIFT_DAYS} days and AI Gateway priced nothing, so the two were not compared. Either the gateway's analytics cannot be seen (Cloudflare answers a token without AI Gateway: Read with no rows, not an error; billing reads them with CLOUDFLARE_USAGE_TOKEN, then CLOUDFLARE_BILLING_TOKEN), or model calls went around the gateway.", | |
| 485 | + | dollars(drift.ours as i64) | |
| 486 | + | ); | |
| 487 | + | } | |
| 476 | 488 | let lower = drift.ours < drift.cloudflare; | |
| 477 | 489 | let mut detail = format!( | |
| 478 | 490 | "Models: AI Gateway priced g1t's own provider traffic at {} over the last {DRIFT_DAYS} days; the ledger's model cost for the same days is {} ({:+.1}%). {}", | |
| 2046 | 2058 | // Gateway traffic with nothing on the ledger at all: cost drift and a leak. | |
| 2047 | 2059 | let none = drifts("models", &[day("models", 2_000_000, 0, 0, 0.0, 0.0)], 10.0, false, 100_000); | |
| 2048 | 2060 | assert_eq!(none.iter().map(|d| d.kind).collect::<Vec<_>>(), vec![DriftKind::Cost, DriftKind::Leak]); | |
| 2049 | − | // Within the threshold, or before the gateway was ever read: nothing. | |
| 2061 | + | // Within the threshold: nothing. | |
| 2050 | 2062 | assert!(drifts("models", &[day("models", 1_050_000, 1_000_000, 1_200_000, 0.0, 0.0)], 10.0, false, 100_000).is_empty()); | |
| 2051 | − | assert!(drifts("models", &[day("models", 0, 1_000_000, 1_200_000, 0.0, 0.0)], 10.0, false, 100_000).is_empty()); | |
| 2063 | + | // The gateway priced nothing against a ledger that has model cost: | |
| 2064 | + | // not agreement (a token that cannot see AI Gateway reads as no | |
| 2065 | + | // rows), so it is said. Under the minimum, or no model cost: nothing. | |
| 2066 | + | let silent = drifts("models", &[day("models", 0, 1_000_000, 1_200_000, 0.0, 0.0)], 10.0, false, 100_000); | |
| 2067 | + | assert_eq!(silent, vec![Drift { bucket: "models".into(), kind: DriftKind::Cost, ours: 1_000_000.0, cloudflare: 0.0, delta_percent: None }]); | |
| 2068 | + | let said = models_detail(&silent[0], &costs::GatewayCaveats::default()); | |
| 2069 | + | assert!(said.contains("$1.00") && said.contains("priced nothing") && said.contains("AI Gateway: Read"), "{said}"); | |
| 2070 | + | assert!(drifts("models", &[day("models", 0, 50_000, 60_000, 0.0, 0.0)], 10.0, false, 100_000).is_empty()); | |
| 2071 | + | assert!(drifts("models", &[day("models", 0, 0, 0, 0.0, 0.0)], 10.0, false, 100_000).is_empty()); | |
| 2052 | 2072 | // The detail says which way and why it may be off. | |
| 2053 | 2073 | let caveats = costs::GatewayCaveats { cache_read_tokens: 3_000_000.0, unpriced: vec!["anthropic_claude_new_1".into()], ..Default::default() }; | |
| 2054 | 2074 | let detail = models_detail(&short[0], &caveats); |