Skip to content

Commit

Artifacts: an hourly view of operations and errors, and SSH must meter its git before it ships

scripts/ops/artifacts-usage.mjs --hours DAY shows one UTC day hour by hour (Cloudflare's pull/push/create/fork/delete and errors against git_operations), then the day's errors by message and repository, so a fix that lands mid-day is judged on the hours after it. On 2026-10-07 the pull ratio was 1.15-1.35 before the 09:30 metering fixes and 0.94-0.97 after. docs/ARTIFACTS.md and crates/sshd say what SSH needs before it ships: its bridge reaches the store directly, so its git is never counted. Either report each session's operations like backups do, or go through git_http.

syntaqxcommitted Parent94cb905Browse files
3 files+121−130/3 viewed
+4−0
44 //!
55 //! Connections arrive either as raw TCP or wrapped in a WebSocket, which is
66 //! 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").
711
812 mod api;
913 mod git;
+20−2
348348 workspaces, because the 1 TB account limit would stop every push.
349349 - **Before agent volume ramps (about 1,000 pull requests a day):** R2 deletion, R3, R4, R5.
350350 - **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.
351353 - Everything else is resilience and exit planning, ideally done while the product is still invite-only.
352354
353355 ## 9. What was built (2026-10-06)
384386 node scripts/ops/artifacts-usage.mjs # last 31 days, a table
385387 node scripts/ops/artifacts-usage.mjs --days 7 --json > usage.json
386388 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
387390 ```
388391
389392 It prints, per day, Cloudflare's `pull`, `push`, `create`, `fork` and `delete` events and its
400403 lost before they were written. Sandboxes are not it: every sandbox but a backup's clones,
401404 fetches and pushes through g1t's git endpoints (see "2026-10-07: where the gap came from").
402405 - 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.
403408 - Binding calls do appear: Cloudflare's events include `read` and `token_create` actions (and
404409 `namespace_*`) besides the five documented ones. If Cloudflare says they are billed, map the
405410 `binding.*` meters in `operation_mapping`.
450455 `git.info_refs`, `git.ls_refs`, `git.fetch` and `git.receive_pack`. Git an agent runs itself
451456 in its sandbox has the same remote and no other credential, so it is metered the same way.
452457 - `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`.
458+ endpoints do not exist yet. **SSH must not ship until its git is metered.** Its bridge
459+ (`crates/sshd/src/git.rs`) talks to the store directly with the handed-out token, so nothing
460+ in `git_http` sees it: every SSH clone, fetch and push would be an operation Cloudflare bills
461+ and g1t never counts. Two ways to close it, either is enough:
462+ 1. Report, as backups do: when a session ends, sshd posts the service, the repo and the bytes
463+ each way to a repos RPC that records `git.info_refs` plus `git.fetch` or
464+ `git.receive_pack` against the repo's store key (`meters::record`, like `backups.rs`
465+ `meter_fetch`). A session that dies before reporting is lost, so count the operation when
466+ `git_access` hands out the token and add only the bytes from the report.
467+ 2. Send the bridge through `git_http` instead of the store, with the user's identity, so SSH
468+ is metered, cached and protected (branch and push protection) like HTTPS. This also closes
469+ M15 for SSH.
470+
471+ Building this is not small today: the Worker side (`/_internal/ssh/user` and
472+ `/_internal/ssh/access`) does not exist either, so it belongs with shipping SSH.
455473 - The one sandbox that reads the store directly is a nightly backup (`backups.rs`
456474 `store.handout`): its runner reports the clone with `fetched_bytes`, metered as
457475 `internal.git.info_refs` and `internal.git.backup_fetch` (g1t's cost, never a workspace's).
+97−11
1111 //
1212 // CLOUDFLARE_API_TOKEN=<token with Account Analytics: Read> \
1313 // node scripts/ops/artifacts-usage.mjs [--days 31] [--json]
14+// node scripts/ops/artifacts-usage.mjs --hours 2026-10-07
1415 //
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+//
1520 // The D1 queries run through Wrangler with the same environment (so the
1621 // token needs D1: Read too), or with CLOUDFLARE_D1_TOKEN when that is set,
1722 // or as you are logged in (`npx wrangler login`) when neither has it.
3237 };
3338 const days = Math.min(31, Math.max(1, Number(option("--days", "31")) || 31));
3439 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+}
3545
3646 const auth = cloudflareAuth();
3747 if (!auth) {
4656 const start = new Date(end.getTime() - days * 24 * 3600 * 1000);
4757 const day = (date) => date.toISOString().slice(0, 10);
4858
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+
49143 /** Cloudflare's own count, by day, event kind and type (and namespace). */
50144 async function cloudflare() {
51145 const query = `query ArtifactsUsage($accountTag: String!, $start: Time!, $end: Time!) {
63157 }
64158 }
65159 }`;
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 ?? [];
76162 return groups
77163 .filter((group) => !NAMESPACE || group.dimensions.repositoryNamespace === NAMESPACE)
78164 .map((group) => ({
199285 );
200286 }
201287
202−main().catch((error) => {
288+(hoursOf ? hourly(hoursOf) : main()).catch((error) => {
203289 console.error(error.message);
204290 process.exit(1);
205291 });