Public: anyone can read this; the crew can change it.

The forge reports npm registry bytes per hour, so the steward can see the bun cache working #7694

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/7546-the-forge-reports-npm-registry-bytes-per → main 7 commits fork

  • Merged. torque/7546-the-forge-reports-npm-registry-bytes-per was merged into main as 8fdbb90.

Review

Sign in to review this change.
16 files changed +429 −38
docs/MCP.md +1 −1
@@ -61,7 +61,7 @@ for a tunnel and a forge that sleeps.
6161 | `board_write` | `{ slug, board }` (person admins): write a whole board under a slug, made or replaced: `{ title, range, refresh, panels: [{ title, chart, query, w, h }] }` | yes |
6262 | `boards` | `{ slug?, panel?, from?, to?, step? }` (person admins): without `slug`, lists the forge's boards (slug, title, range, refresh, panels); with `slug`, that board; with `panel` (place from 0, up to 23), that panel's data over the `from`/`to` window and `step` | |
6363 | `branches` | `{ repo, name?, action?, from?, kind? }`: without `name`, lists the branches; `kind: "tags"` lists the tags instead (as GET `/api/v1/repos/:owner/:name/tags` does, newest first; no `name` or `action`); with `name` and `action` (`create` from `from`, or `delete`) it does what the branches page does: the default branch is never deleted, only a writer may act | yes |
64 | `capacity` | `{ key?, value?, dryRun? }` (forge admin): the forge's capacity and guards, live (crew and checks concurrency, check shards, cell egress, the new-account guard, the sign-in edge, queue depth, child forges; only the first six are settable by `key`); with `key` and `value` (`""` restores the default), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token | yes |
64 | `capacity` | `{ key?, value?, dryRun? }` (forge admin): the forge's capacity and guards, live (crew and checks concurrency, check shards, cell egress, the new-account guard, the sign-in edge, queue depth, child forges, `egress.npmBytesLastHour`; only the first six are settable by `key`); with `key` and `value` (`""` restores the default), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token | yes |
6565 | `claim_link` | `{ name }`: a new claim link for a child you spawned that nobody has claimed; the old one stops working. An admin's | yes |
6666 | `close` | `{ repo, number, state?, reason?, into? }`: close an issue or a pull (`state` closed by default) or reopen it (`open`); the same rules and refusals as `PATCH …/issues/:number`: an agent closes only what the rules give it and reopens nothing; answers `state` and `closeReason` | yes |
6767 | `cloud_account` | `{ verb, provider, name, value?, to?, allowed?, repo? }`: change a cloud account: `attach` or `rotate` a token (`value`), `verify` it, `rename` it (`to`), `remove` it, `allow_writes` (`allowed` true or false) or `audit` (`repo` owner/name); a forge admin with an admin-scope token; answers a one-line `says` | yes |
docs/METRICS.md +20
@@ -11,6 +11,8 @@ not an admin, 200 for an admin's `ro` token. It is never cached
1111 | `forge_egress_connections` | gauge | `repo`, `destination` | Open egress connections. |
1212 | `forge_egress_bytes_in` | gauge | `repo`, `destination` | Bytes received from the destination over open connections. |
1313 | `forge_egress_bytes_out` | gauge | `repo`, `destination` | Bytes sent to the destination over open connections. |
14 | `forge_egress_received_bytes_total` | counter | `destination` | Bytes received from the destination since the process started, every app and check together. |
15 | `forge_egress_sent_bytes_total` | counter | `destination` | Bytes sent to the destination since the process started. |
1416 | `forge_http_request_duration_seconds` | histogram | `pattern` (plus `le`) | Seconds taken to answer a request, by the route pattern matched (never the raw path), since the process started. Buckets are `route_timing`'s edges, 1 ms to 2.5 s, in seconds. |
1517 | `forge_check_duration_seconds` | histogram | (plus `le`) | Seconds a repository check took from start to finish, for checks that ended since the process started. Buckets 5 s to 30 min. |
1618 | `forge_check_queue_depth` | gauge | none | Checks queued and not yet started. |
@@ -65,3 +67,21 @@ scrape_configs:
6567 `forge_egress_connections`, `forge_egress_bytes_in` and `forge_egress_bytes_out`
6668 per `repo` and `destination` as Prometheus text, so a leaked upstream socket
6769 shows as a gauge that never falls (#3719).
70
71 Beside them, `forge_egress_received_bytes_total` and `forge_egress_sent_bytes_total`
72 count every byte since the process started, keyed by `destination` alone (never
73 by run, so the series stay one per host). They show the bun cache working
74 (#7546): on a warm cache,
75 `rate(forge_egress_received_bytes_total{destination="registry.npmjs.org:443"}[1h])`
76 stays near zero, and a cold one is hundreds of megabytes an hour (#7541).
77 Without Prometheus, the same hour is read four ways:
78
79 - MCP `capacity` (forge admin) answers `egress.npmBytesLastHour` and the ten
80 busiest `egress.destinations` with their totals and last hour.
81 - `/settings/capacity` prints it in the Egress connections card.
82 - The terminal's Egress connections screen (About, `c`, site admin) prints the
83 same line above the open connections.
84 - Each check's install step in a cell logs one line, `install fetched 0 B from
85 the registry` on a warm cache: what the cell's egress proxy received. A check
86 outside a cell logs none: its repository's bun cache is shared, so no figure
87 there would be its own.
src/actions/capacity.ts +21 −1
@@ -22,6 +22,7 @@ import {
2222 type CapacityChange,
2323 type CapacityKey,
2424 } from "../core/capacity.ts";
25 import { egressTotals, npmBytesLastHour, type EgressTotal } from "../apps/egress.ts";
2526 import { passkeyIds } from "../core/passkeys.ts";
2627 import type { User } from "../core/users.ts";
2728 import { err, ok, type Result } from "../kernel/result.ts";
@@ -49,6 +50,24 @@ export interface CapacityRow {
4950 readonly changed?: { readonly at: string; readonly by: string };
5051 }
5152
53 /**
54 * What the egress proxies received over the last hour (#7546), the npm
55 * registry first: a warm bun cache keeps `npmBytesLastHour` near zero, so a
56 * figure in the hundreds of megabytes is the cache gone cold again (#7541).
57 */
58 export interface EgressHour {
59 readonly npmBytesLastHour: number;
60 /** The ten destinations that sent most this hour, with their totals since boot. */
61 readonly destinations: readonly EgressTotal[];
62 }
63
64 export function egressHour(now = Date.now()): EgressHour {
65 return {
66 npmBytesLastHour: npmBytesLastHour(now),
67 destinations: egressTotals(now).slice(0, 10),
68 };
69 }
70
5271 function admin(actor: User | undefined): Result<User, "not_found"> {
5372 return actor?.isAdmin === true && actor.kind !== "agent"
5473 ? ok(actor)
@@ -70,7 +89,7 @@ export function changedOf(db: Database, key: CapacityKey): { changed?: { at: str
7089 export function listCapacity(
7190 app: App,
7291 actor: User | undefined,
73 ): Result<{ settings: CapacityRow[]; changes: CapacityChange[] }, "not_found"> {
92 ): Result<{ settings: CapacityRow[]; changes: CapacityChange[]; egress: EgressHour }, "not_found"> {
7493 const who = admin(actor);
7594 if (!who.ok) return who;
7695 return ok({
@@ -85,6 +104,7 @@ export function listCapacity(
85104 ...changedOf(app.db, key),
86105 })),
87106 changes: capacityChanges(app.db),
107 egress: egressHour(),
88108 });
89109 }
90110
src/apps/egress.ts +123
@@ -109,6 +109,121 @@ export function egressSnapshot(): EgressRow[] {
109109 return rows.sort((a, b) => b.open - a.open || a.label.localeCompare(b.label));
110110 }
111111
112 /**
113 * Bytes moved since the forge started, per destination alone (#7546): the
114 * counters `/metrics` serves, and a minute-by-minute ring of the last hour,
115 * so a cold bun cache shows as registry bytes without a packet capture.
116 * Keyed by `host:port` and never by run label, so it holds one entry per
117 * host the apps reach, not one per run; past `MOVED_MAX` destinations the
118 * rest are counted under `other`, save the registry, which always keeps its
119 * own entry so the headline figure is never lost there.
120 */
121 interface Moved {
122 bytesIn: number;
123 bytesOut: number;
124 /** Minute `m` (ms since epoch / 60 000) lands in slot `m % 60`. */
125 readonly minute: number[];
126 readonly minuteIn: number[];
127 }
128
129 const MOVED_MAX = 256;
130 const HOUR_MINUTES = 60;
131 const moved = new Map<string, Moved>();
132 /** label -> bytes in, for the labels someone is metering now (`meterEgress`). */
133 const meters = new Map<string, { bytesIn: number; holders: number }>();
134
135 /** Count `bytesIn` received from and `bytesOut` sent to `destination`. */
136 export function recordEgress(
137 label: string,
138 destination: string,
139 bytesIn: number,
140 bytesOut: number,
141 now = Date.now(),
142 ): void {
143 const key =
144 destination === NPM_REGISTRY || moved.has(destination) || moved.size < MOVED_MAX
145 ? destination
146 : "other";
147 let m = moved.get(key);
148 if (m === undefined)
149 moved.set(key, (m = { bytesIn: 0, bytesOut: 0, minute: Array(HOUR_MINUTES).fill(-1), minuteIn: Array(HOUR_MINUTES).fill(0) }));
150 m.bytesIn += bytesIn;
151 m.bytesOut += bytesOut;
152 const minute = Math.floor(now / 60_000);
153 const slot = minute % HOUR_MINUTES;
154 if (m.minute[slot] !== minute) {
155 m.minute[slot] = minute;
156 m.minuteIn[slot] = 0;
157 }
158 m.minuteIn[slot] = (m.minuteIn[slot] ?? 0) + bytesIn;
159 const meter = meters.get(label);
160 if (meter !== undefined) meter.bytesIn += bytesIn;
161 }
162
163 export interface EgressTotal {
164 readonly destination: string;
165 /** Bytes received from it since the forge started. */
166 readonly bytesIn: number;
167 /** Bytes sent to it since the forge started. */
168 readonly bytesOut: number;
169 /** Bytes received from it in the 60 minutes up to `now`, this minute included. */
170 readonly bytesInLastHour: number;
171 }
172
173 /** Bytes `m` received in the 60 minutes up to `minute`, that minute included. */
174 function lastHour(m: Moved, minute: number): number {
175 let hour = 0;
176 for (let i = 0; i < HOUR_MINUTES; i++) {
177 const at = m.minute[i] ?? -1;
178 if (at > minute - HOUR_MINUTES && at <= minute) hour += m.minuteIn[i] ?? 0;
179 }
180 return hour;
181 }
182
183 /** Every destination reached since the forge started, most received this hour first. */
184 export function egressTotals(now = Date.now()): EgressTotal[] {
185 const minute = Math.floor(now / 60_000);
186 const rows: EgressTotal[] = [];
187 for (const [destination, m] of moved)
188 rows.push({ destination, bytesIn: m.bytesIn, bytesOut: m.bytesOut, bytesInLastHour: lastHour(m, minute) });
189 return rows.sort((a, b) => b.bytesInLastHour - a.bytesInLastHour || a.destination.localeCompare(b.destination));
190 }
191
192 /** The package registry every install step reaches (`apps/contain.ts` STEP_HOSTS). */
193 export const NPM_REGISTRY = "registry.npmjs.org:443";
194
195 /** Bytes received from the npm registry over the last hour, across every app and check. */
196 export function npmBytesLastHour(now = Date.now()): number {
197 const m = moved.get(NPM_REGISTRY);
198 return m === undefined ? 0 : lastHour(m, Math.floor(now / 60_000));
199 }
200
201 /**
202 * Count what `label`'s proxy receives from now until `stop()`, which answers
203 * the bytes in. Only labels being metered are kept, and each goes with its
204 * last holder, so a run label never outlives the step that asked.
205 */
206 export function meterEgress(label: string): { stop(): number } {
207 const meter = meters.get(label) ?? { bytesIn: 0, holders: 0 };
208 meters.set(label, meter);
209 meter.holders++;
210 const start = meter.bytesIn;
211 let stopped = false;
212 return {
213 stop() {
214 const bytes = meter.bytesIn - start;
215 if (!stopped && --meter.holders === 0) meters.delete(label);
216 stopped = true;
217 return bytes;
218 },
219 };
220 }
221
222 /** For tests: forget every cumulative count. */
223 export function resetEgressTotals(): void {
224 moved.clear();
225 }
226
112227 /** The ports a declared host may be reached on when its entry names none. */
113228 export const WEB_PORTS: readonly number[] = [80, 443];
114229
@@ -230,6 +345,9 @@ interface Conn {
230345 done: boolean;
231346 /** The destination's counts once dialled; the data path adds to it directly. */
232347 tally?: Tally;
348 /** Whose proxy and where to, once dialled, for the cumulative counts. */
349 label: string;
350 destination?: string;
233351 /** When bytes last moved either way (ms). */
234352 last: number;
235353 /** Frees the destination's slot and counts it closed; set once dialled. */
@@ -394,6 +512,7 @@ async function open(
394512 count.open++;
395513 client.data.tally = count;
396514 const label = policy.label ?? "";
515 client.data.destination = destination;
397516 client.data.release = () => untally(label, destination);
398517 const ended = () => {
399518 if (!client.data.done) {
@@ -410,6 +529,7 @@ async function open(
410529 if (client.data.done) return;
411530 client.data.last = Date.now();
412531 count.bytesIn += chunk.length;
532 recordEgress(label, destination, chunk.length, 0);
413533 send(client, client.data.toClient, chunk);
414534 if (queued(client.data.toClient) > (policy.queueMax ?? QUEUE_MAX)) {
415535 drop(client);
@@ -443,6 +563,7 @@ async function open(
443563 const first = concat(prefix, client.data.head.subarray(end));
444564 if (first.length > 0) {
445565 count.bytesOut += first.length;
566 recordEgress(label, destination, 0, first.length);
446567 send(peer, client.data.toPeer, first);
447568 }
448569 } catch {
@@ -471,6 +592,7 @@ export function startEgress(
471592 toPeer: [],
472593 toClient: [],
473594 done: false,
595 label: policy.label ?? "",
474596 last: Date.now(),
475597 release: undefined,
476598 };
@@ -481,6 +603,7 @@ export function startEgress(
481603 if (conn.peer !== undefined) {
482604 if (conn.done) return;
483605 if (conn.tally !== undefined) conn.tally.bytesOut += chunk.length;
606 if (conn.destination !== undefined) recordEgress(conn.label, conn.destination, 0, chunk.length);
484607 send(conn.peer, conn.toPeer, chunk);
485608 return;
486609 }
src/apps/egress_metrics.ts +15 −3
@@ -1,16 +1,19 @@
11 /**
22 * The egress tally as Prometheus text (#3719): one line per app and
33 * destination for open connections and the bytes they have moved, so a leaked
4 * upstream socket shows as a gauge that never falls.
4 * upstream socket shows as a gauge that never falls. Beside them, counters of
5 * every byte moved since the forge started, per destination alone (#7546):
6 * `rate(forge_egress_received_bytes_total{destination="registry.npmjs.org:443"}[1h])`
7 * is the bun cache working, or not.
58 */
6 import type { EgressRow } from "./egress.ts";
9 import type { EgressRow, EgressTotal } from "./egress.ts";
710
811 /** A label value, escaped as the exposition format asks. */
912 function label(value: string): string {
1013 return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
1114 }
1215
13 export function egressMetrics(rows: readonly EgressRow[]): string {
16 export function egressMetrics(rows: readonly EgressRow[], totals: readonly EgressTotal[] = []): string {
1417 const series: [string, string, (r: EgressRow) => number][] = [
1518 ["forge_egress_connections", "Open egress connections.", (r) => r.open],
1619 ["forge_egress_bytes_in", "Bytes received from the destination over open connections.", (r) => r.bytesIn],
@@ -22,5 +25,14 @@ export function egressMetrics(rows: readonly EgressRow[]): string {
2225 for (const r of rows)
2326 lines.push(`${name}{repo="${label(r.label)}",destination="${label(r.destination)}"} ${value(r)}`);
2427 }
28 const counters: [string, string, (t: EgressTotal) => number][] = [
29 ["forge_egress_received_bytes", "Bytes received from the destination since the forge started.", (t) => t.bytesIn],
30 ["forge_egress_sent_bytes", "Bytes sent to the destination since the forge started.", (t) => t.bytesOut],
31 ];
32 for (const [name, help, value] of counters) {
33 // A counter's family is named without `_total`; its samples carry it.
34 lines.push(`# HELP ${name} ${help}`, `# TYPE ${name} counter`);
35 for (const t of totals) lines.push(`${name}_total{destination="${label(t.destination)}"} ${value(t)}`);
36 }
2537 return `${lines.join("\n")}\n`;
2638 }
src/checks/install.ts +10
@@ -19,6 +19,7 @@
1919 import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2020 import { tmpdir } from "node:os";
2121 import { dirname, join } from "node:path";
22 import { formatBytes } from "../core/storage.ts";
2223 import { err, ok, type Result } from "../kernel/result.ts";
2324
2425 /** What an install prints when the machine, not the code, failed it. */
@@ -35,6 +36,15 @@ export const LOCK_STALE_MS = 15 * 60_000;
3536
3637 const LOCK_POLL_MS = 50;
3738
39 /**
40 * The one line an install step logs about the registry (#7546): `0 B` on a
41 * warm cache. A cell's figure is what its egress proxy received from the
42 * registry; uncontained, nothing is logged.
43 */
44 export function installFetchedLine(bytes: number): string {
45 return `install fetched ${formatBytes(Math.max(0, bytes))} from the registry`;
46 }
47
3848 export type InstallOutcome =
3949 | { readonly ok: true }
4050 | { readonly ok: false; readonly error: string; readonly infrastructure: boolean };
src/checks/service.ts +43 −23
@@ -95,7 +95,15 @@ import { summariseCheckFailure } from "./summary.ts";
9595 import type { Result } from "../kernel/result.ts";
9696 import { placementFromEnv, type Placement } from "./placement.ts";
9797 import { JOB_VANISHED, runJobStep } from "./job_step.ts";
98 import { cellInstall, checkInstallCache, installWithRetry, prefillInstallCache, withLock } from "./install.ts";
98 import {
99 cellInstall,
100 checkInstallCache,
101 installFetchedLine,
102 installWithRetry,
103 prefillInstallCache,
104 withLock,
105 } from "./install.ts";
106 import { meterEgress } from "../apps/egress.ts";
99107 import { checkStage, type GateStage } from "./gate_reason.ts";
100108 import { proofsInFlight } from "./proof.ts";
101109 import { extraGates, GATE_DEADLINE_MS, gateQueued, gateRunId, gatesInFlight, watchGates } from "./queue.ts";
@@ -1071,29 +1079,41 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
10711079 cell: revealed,
10721080 });
10731081 };
1074 const installed = await installWithRetry(
1075 async (log) => {
1076 if (shared === undefined) {
1077 if (warm !== undefined) {
1078 const fill = await prefillInstallCache({
1079 cache: warm,
1080 dir,
1081 env,
1082 log,
1083 signal,
1084 timeoutMs: manifest.timeout * 1000,
1085 });
1086 if (!fill.ok) return fill;
1087 filled = fill.value;
1082 // What the cell's proxy received, for the one line that shows the cache
1083 // working (#7546). Uncontained, nothing is logged: the shared cache
1084 // is not metered, and its growth is wrong under concurrent checks.
1085 // Stopped in `finally`, so a throw never leaves the label metered.
1086 const meter = cell === undefined ? undefined : meterEgress(`check:${run.id}`);
1087 let fetched: number | undefined;
1088 let installed: Awaited<ReturnType<typeof installWithRetry>>;
1089 try {
1090 installed = await installWithRetry(
1091 async (log) => {
1092 if (shared === undefined) {
1093 if (warm !== undefined) {
1094 const fill = await prefillInstallCache({
1095 cache: warm,
1096 dir,
1097 env,
1098 log,
1099 signal,
1100 timeoutMs: manifest.timeout * 1000,
1101 });
1102 if (!fill.ok) return fill;
1103 filled = fill.value;
1104 }
1105 return once(log);
10881106 }
1089 return once(log);
1090 }
1091 mkdirSync(shared, { recursive: true });
1092 const held = await withLock(`${shared}.lock`, () => once(log), { signal });
1093 return held.ok ? held.value : held;
1094 },
1095 { log: write, signal, retryMs: options.installRetryMs },
1096 );
1107 mkdirSync(shared, { recursive: true });
1108 const held = await withLock(`${shared}.lock`, () => once(log), { signal });
1109 return held.ok ? held.value : held;
1110 },
1111 { log: write, signal, retryMs: options.installRetryMs },
1112 );
1113 } finally {
1114 fetched = meter?.stop();
1115 }
1116 if (fetched !== undefined) write(installFetchedLine(fetched));
10971117 if (!installed.ok)
10981118 return {
10991119 ok: false,
src/http/api.ts +2 −2
@@ -335,7 +335,7 @@ import { upstreamRoutes } from "./api/upstream.ts";
335335 import type { Router } from "./router.ts";
336336 import { clientAddress } from "./peer.ts";
337337 import { manifestFor } from "../actions/here.ts";
338 import { egressSnapshot } from "../apps/egress.ts";
338 import { egressSnapshot, egressTotals } from "../apps/egress.ts";
339339 import { egressMetrics } from "../apps/egress_metrics.ts";
340340 import { forgeMetrics } from "../metrics/forge.ts";
341341 import { appSources } from "../metrics/app_sources.ts";
@@ -3105,7 +3105,7 @@ export function apiRoutes(router: Router<Handler>): void {
31053105 router.add("GET", "/metrics", (ctx) => {
31063106 const denied = accountAdmin(ctx, "ro");
31073107 if (denied) return denied;
3108 return new Response(egressMetrics(egressSnapshot()) + forgeMetrics(ctx.app.db, appSources(ctx.app)), {
3108 return new Response(egressMetrics(egressSnapshot(), egressTotals()) + forgeMetrics(ctx.app.db, appSources(ctx.app)), {
31093109 headers: {
31103110 "content-type": "text/plain; version=0.0.4; charset=utf-8",
31113111 "cache-control": "no-store",
src/http/mcp/tools/capacity.ts +1 −1
@@ -5,7 +5,7 @@ import { good, bad, type Tool } from "../shared.ts";
55
66 export const tool: Tool = {
77 name: "capacity",
8 doc: "`{ key?, value?, dryRun? }` (forge admin): the forge's capacity and guards, live (crew and checks concurrency, check shards, cell egress, the new-account guard, the sign-in edge, queue depth, child forges; only the first six are settable by `key`); with `key` and `value` (`\"\"` restores the default), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token",
8 doc: "`{ key?, value?, dryRun? }` (forge admin): the forge's capacity and guards, live (crew and checks concurrency, check shards, cell egress, the new-account guard, the sign-in edge, queue depth, child forges, `egress.npmBytesLastHour`; only the first six are settable by `key`); with `key` and `value` (`\"\"` restores the default), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token",
99 description:
1010 "Forge capacity and guards, live. With `key` and `value` (\"\" restores default), plans.",
1111 inputSchema: {
src/tui/screens/egress.ts +9 −4
@@ -10,7 +10,7 @@ import {
1010 revokeAppEgress,
1111 type EgressRow,
1212 } from "../../actions/egress_grants.ts";
13 import { egressSnapshot } from "../../apps/egress.ts";
13 import { egressSnapshot, npmBytesLastHour } from "../../apps/egress.ts";
1414 import { bytes } from "../../system/format.ts";
1515 import type { Repo } from "../../core/repos.ts";
1616 import type { Ctx, Screen } from "../app/app.ts";
@@ -89,8 +89,9 @@ export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
8989
9090 /**
9191 * The egress connections open right now, per app and destination: the
92 * Capacity page's card, for a site admin. Read again on every draw, so it
93 * moves as connections do; anyone else is offered nothing.
92 * Capacity page's card, for a site admin, under the bytes the npm registry
93 * sent over the last hour (#7546). Read again on every draw, so it moves as
94 * connections do; anyone else is offered nothing.
9495 */
9596 export function connectionsScreen(f: Session, ctx: Ctx): Screen {
9697 const state = new ListState();
@@ -108,7 +109,11 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen {
108109 const c = ctx.theme.c;
109110 const rows = lines();
110111 state.clamp(rows.length);
111 const [head, list] = split(area, "v", [len(1), fill(1)]);
112 const [npm, head, list] = split(area, "v", [len(1), len(1), fill(1)]);
113 if (f.user?.isAdmin === true) {
114 const sent = `The npm registry sent ${bytes(npmBytesLastHour())} over the last hour.`;
115 s.text(npm!.x, npm!.y, truncate(sent, npm!.w), style(c.mutedFg, c.bg), npm!.x + npm!.w);
116 }
112117 const note =
113118 rows.length === 0
114119 ? "No app holds an egress connection open."
src/web/pages/capacity.ts +6 −1
@@ -76,7 +76,7 @@ export function capacityPage(ctx: Ctx): Response {
7676 listCapacity(ctx.app, ctx.auth.user),
7777 );
7878 if (!listed.ok) return notFoundPage(ctx);
79 const { settings, changes } = listed.value;
79 const { settings, changes, egress } = listed.value;
8080 const errs = timePhase(ctx.request, "errors", () =>
8181 ctx.app.signals.errors.recent(10),
8282 );
@@ -158,14 +158,19 @@ export function capacityPage(ctx: Ctx): Response {
158158 },
159159 );
160160 const open = egressSnapshot();
161 // The bun cache at work (#7546): a warm cache keeps this near zero.
162 const npm = html`The npm registry sent
163 <span class="mono">${bytes(egress.npmBytesLastHour)}</span> over the last hour.`;
161164 const connections = card(
162165 open.length === 0
163166 ? html`<div class="card-body">
167 <p class="muted small" id="egress-npm">${npm}</p>
164168 <p class="muted small" id="egress-open">
165169 No app holds an egress connection open.
166170 </p>
167171 </div>`
168172 : html`<ul class="list" id="egress-open">
173 ${row({ id: "egress-npm", lead: icon("activity"), title: npm })}
169174 ${open.map((r) =>
170175 row({
171176 lead: icon("activity"),
test/capacity.test.ts +20 −1
@@ -18,7 +18,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts";
1818 import { assignIssue, openIssue } from "../src/actions/issues.ts";
1919 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
2020 import { enableApp } from "../src/actions/apps.ts";
21 import { startEgress } from "../src/apps/egress.ts";
21 import { NPM_REGISTRY, recordEgress, resetEgressTotals, startEgress } from "../src/apps/egress.ts";
2222 import { AppsRuntime } from "../src/apps/runtime.ts";
2323 import { startChecks, type ChecksHandle } from "../src/checks/service.ts";
2424 import {
@@ -142,6 +142,25 @@ describe("capacity: only a forge admin reads or changes it", () => {
142142 await rm(dataDir, { recursive: true, force: true });
143143 });
144144
145 test("MCP capacity and the page answer the npm registry's bytes over the last hour (#7546)", async () => {
146 resetEgressTotals();
147 recordEgress("check:r1", NPM_REGISTRY, 3_000_000, 4_000);
148 recordEgress("check:r1", "example.com:443", 10, 0);
149 const read = await mcp(aliceRo, {});
150 expect(read.error).toBe(false);
151 const egress = (JSON.parse(read.text) as { egress: { npmBytesLastHour: number; destinations: { destination: string }[] } }).egress;
152 expect(egress.npmBytesLastHour).toBe(3_000_000);
153 // Other files' proxies share this process's tally; only the order of ours is asserted.
154 const named = egress.destinations.map((d) => d.destination);
155 expect(named[0]).toBe(NPM_REGISTRY);
156 expect(named).toContain("example.com:443");
157 const cookie = await login("alice");
158 const page = await (await fetch(`${servers.url}/settings/capacity`, { headers: { cookie } })).text();
159 // 3,000,000 B / 1024² = 2.86 MiB.
160 expect(page).toMatch(/id="egress-npm"[\s\S]*?2\.9 MiB/);
161 resetEgressTotals();
162 });
163
145164 test("a member, an agent and nobody are told it is not there, at every door", async () => {
146165 expect(listCapacity(app, bob).ok).toBe(false);
147166 expect(listCapacity(app, agent).ok).toBe(false);
test/check-install.test.ts +5
@@ -27,6 +27,7 @@ import { INFRA_SUMMARY, listRuns, type Run } from "../src/core/runs.ts";
2727 import { createUser } from "../src/core/users.ts";
2828 import type { Repo } from "../src/core/repos.ts";
2929 import { serve, type Servers } from "../src/http/server.ts";
30 import { readRunLog } from "../src/runs/log.ts";
3031 import { err, ok, type Result } from "../src/kernel/result.ts";
3132
3233 const never = new AbortController().signal;
@@ -220,6 +221,10 @@ describe("a check whose install fails on the machine", () => {
220221 expect(runs[0]!.state).toBe("ok");
221222 // Uncontained, the repository's own cache is the one bun is pointed at.
222223 expect(existsSync(checkInstallCache(dataDir, flaky.id))).toBe(true);
224 // Uncontained, no install line: its shared cache is not metered (#7546).
225 expect(
226 readRunLog(dataDir, runs[0]!.id).lines.some((l) => l.startsWith("install fetched")),
227 ).toBe(false);
223228 });
224229
225230 test("failing twice is infrastructure: queued again, not red; a second time it fails saying so", async () => {
test/egress-admin.test.ts +7 −1
@@ -13,7 +13,7 @@ import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1313 import { listEgressGrants } from "../src/core/egress_grants.ts";
1414 import type { Repo } from "../src/core/repos.ts";
1515 import { createUser, type User } from "../src/core/users.ts";
16 import { startEgress } from "../src/apps/egress.ts";
16 import { NPM_REGISTRY, recordEgress, resetEgressTotals, startEgress } from "../src/apps/egress.ts";
1717 import { createForgeApp } from "../src/tui/forge.ts";
1818 import { headlessTerminal } from "../src/tui/term/terminal.ts";
1919 import { quietLogs } from "./helpers/globals.ts";
@@ -126,6 +126,8 @@ describe("egress grants in the terminal", () => {
126126 });
127127 client.write(`CONNECT 127.0.0.1:${camera.port} HTTP/1.1\r\n\r\n`);
128128 while (!reply.includes("200")) await Bun.sleep(10);
129 resetEgressTotals();
130 recordEgress("check:r", NPM_REGISTRY, 3_000_000, 0);
129131 const admin = boot(ada);
130132 const other = boot(bob);
131133 try {
@@ -133,11 +135,15 @@ describe("egress grants in the terminal", () => {
133135 await admin.press("c");
134136 await settleUntil(admin.tui, "app:cam/main");
135137 expect(admin.text()).toContain(`127.0.0.1:${camera.port} · 1 open`);
138 // The registry's hour, as the Capacity page and MCP `capacity` read it (#7546).
139 expect(admin.text()).toContain("The npm registry sent 2.9 MiB over the last hour.");
136140 await other.openAbout();
137141 await other.press("c");
138142 expect(other.text()).not.toContain("Egress connections");
139143 expect(other.text()).not.toContain("app:cam/main");
144 expect(other.text()).not.toContain("npm registry");
140145 } finally {
146 resetEgressTotals();
141147 admin.tui.stop();
142148 other.tui.stop();
143149 client.terminate();
test/egress-metrics.test.ts +10
@@ -13,3 +13,13 @@ test("every destination gets its open count and bytes, labels escaped", () => {
1313 test("no connections leaves only the headers", () => {
1414 expect(egressMetrics([])).not.toContain("{");
1515 });
16
17 test("cumulative bytes are counters keyed by destination alone (#7546)", () => {
18 const text = egressMetrics([], [
19 { destination: "registry.npmjs.org:443", bytesIn: 5_000, bytesOut: 70, bytesInLastHour: 5_000 },
20 ]);
21 expect(text).toContain("# TYPE forge_egress_received_bytes counter");
22 expect(text).toContain('forge_egress_received_bytes_total{destination="registry.npmjs.org:443"} 5000');
23 expect(text).toContain('forge_egress_sent_bytes_total{destination="registry.npmjs.org:443"} 70');
24 expect(text).not.toContain("repo=");
25 });
test/egress-totals.test.ts added +136
@@ -0,0 +1,136 @@
1 /**
2 * The egress tally keeps every byte per destination, not only what open
3 * connections moved (#7546): counters that survive a closed connection, the
4 * last hour minute by minute, a meter an install step reads, and a bound on
5 * how many destinations it holds.
6 */
7
8 import { afterEach, beforeEach, expect, test } from "bun:test";
9 import {
10 egressSnapshot,
11 egressTotals,
12 meterEgress,
13 NPM_REGISTRY,
14 npmBytesLastHour,
15 recordEgress,
16 resetEgressTotals,
17 startEgress,
18 } from "../src/apps/egress.ts";
19 import { installFetchedLine } from "../src/checks/install.ts";
20
21 // The tally is the process's own, and `bun test` runs many files in one
22 // process: a proxy test in a file before this one leaves its destinations
23 // behind, so each test starts from nothing, not only ends there.
24 beforeEach(resetEgressTotals);
25 afterEach(resetEgressTotals);
26
27 const T = Date.UTC(2026, 9, 27, 12, 0, 0);
28 const MIN = 60_000;
29
30 test("bytes add up per destination across labels", () => {
31 recordEgress("check:a", NPM_REGISTRY, 1_000, 10, T);
32 recordEgress("check:b", NPM_REGISTRY, 2_500, 5, T);
33 expect(egressTotals(T)).toEqual([
34 { destination: NPM_REGISTRY, bytesIn: 3_500, bytesOut: 15, bytesInLastHour: 3_500 },
35 ]);
36 });
37
38 test("the last hour counts the 60 minutes up to now and drops older ones", () => {
39 recordEgress("x", NPM_REGISTRY, 100, 0, T); // minute 0
40 recordEgress("x", NPM_REGISTRY, 20, 0, T + 30 * MIN); // minute 30
41 recordEgress("x", NPM_REGISTRY, 3, 0, T + 59 * MIN); // minute 59
42 expect(npmBytesLastHour(T + 59 * MIN)).toBe(123);
43 // At minute 60, minute 0 has left the window: 20 + 3.
44 expect(npmBytesLastHour(T + 60 * MIN)).toBe(23);
45 // Minute 60 reuses minute 0's slot and starts it from nothing.
46 recordEgress("x", NPM_REGISTRY, 7, 0, T + 60 * MIN);
47 expect(npmBytesLastHour(T + 60 * MIN)).toBe(30);
48 expect(npmBytesLastHour(T + 200 * MIN)).toBe(0);
49 // The counter never falls: 100 + 20 + 3 + 7.
50 expect(egressTotals(T + 200 * MIN)[0]?.bytesIn).toBe(130);
51 });
52
53 test("no traffic to the registry reads 0", () => {
54 recordEgress("x", "example.com:443", 9, 0, T);
55 expect(npmBytesLastHour(T)).toBe(0);
56 });
57
58 test("destinations are bounded: past 256 the rest count as other", () => {
59 for (let i = 0; i < 300; i++) recordEgress("x", `h${i}.example:443`, 1, 0, T);
60 const totals = egressTotals(T);
61 expect(totals.length).toBe(257);
62 expect(totals.find((t) => t.destination === "other")?.bytesIn).toBe(44);
63 });
64
65 test("the registry keeps its own entry however many destinations came first", () => {
66 for (let i = 0; i < 300; i++) recordEgress("x", `h${i}.example:443`, 1, 0, T);
67 recordEgress("check:a", NPM_REGISTRY, 5_000, 0, T);
68 expect(npmBytesLastHour(T)).toBe(5_000);
69 expect(egressTotals(T).find((t) => t.destination === "other")?.bytesIn).toBe(44);
70 });
71
72 test("a meter counts its label from start to stop, and forgets the label after", () => {
73 recordEgress("check:r", NPM_REGISTRY, 50, 0, T);
74 const meter = meterEgress("check:r");
75 recordEgress("check:r", NPM_REGISTRY, 400, 0, T);
76 recordEgress("check:other", NPM_REGISTRY, 9_999, 0, T);
77 expect(meter.stop()).toBe(400);
78 // A second meter on the same label starts from nothing.
79 const again = meterEgress("check:r");
80 expect(again.stop()).toBe(0);
81 });
82
83 test("the install line reads 0 B on a warm cache", () => {
84 expect(installFetchedLine(0)).toBe("install fetched 0 B from the registry");
85 expect(installFetchedLine(12_345_678)).toBe("install fetched 12.3 MB from the registry");
86 expect(installFetchedLine(-4096)).toBe("install fetched 0 B from the registry");
87 });
88
89 test("bytes through the proxy stay counted after the connection closes", async () => {
90 const upstream = Bun.listen<undefined>({
91 hostname: "127.0.0.1",
92 port: 0,
93 socket: {
94 data(s) {
95 s.end("HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello");
96 },
97 },
98 });
99 const port = 20_000 + Math.floor(Math.random() * 20_000);
100 const label = `check:totals-${port}`;
101 const egress = startEgress({ tcp: port }, { hosts: [], origins: [{ host: "127.0.0.1", port: upstream.port }], label });
102 const meter = meterEgress(label);
103 try {
104 // A raw socket: fetch goes straight to a loopback host, past any proxy.
105 const answer = await new Promise<string>((resolve) => {
106 let got = "";
107 void Bun.connect<undefined>({
108 hostname: "127.0.0.1",
109 port,
110 socket: {
111 open(s) {
112 s.write(`GET http://127.0.0.1:${upstream.port}/ HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n`);
113 },
114 data(_s, chunk) {
115 got += new TextDecoder().decode(chunk);
116 },
117 close() {
118 resolve(got);
119 },
120 },
121 });
122 });
123 expect(answer).toEndWith("hello");
124 const destination = `127.0.0.1:${upstream.port}`;
125 const end = Date.now() + 2_000;
126 while (Date.now() < end && egressSnapshot().some((r) => r.label === label)) await Bun.sleep(20);
127 expect(egressSnapshot().some((r) => r.label === label)).toBe(false);
128 const total = egressTotals().find((t) => t.destination === destination);
129 expect(total?.bytesIn).toBeGreaterThan(5);
130 expect(total?.bytesOut).toBeGreaterThan(0);
131 expect(meter.stop()).toBe(total?.bytesIn ?? -1);
132 } finally {
133 egress.close();
134 upstream.stop(true);
135 }
136 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs