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

GET /api/v1/telemetry/flow tells a poller how fresh it is and answers a repeat inside 5 s with 304 #9070

Merged opened this

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

Merged

torque/forge:torque/9068-get-api-v1-telemetry-flow-tells-a-poller → main 5 commits fork 3 threads · 3 unresolved

  • Merged. torque/9068-get-api-v1-telemetry-flow-tells-a-poller was merged into main as c8a817d.

Review

Sign in to review this change.
4 files changed +111 −7
docs/FLOW.md +14
@@ -39,6 +39,20 @@ numbers, private repositories included for the owner, are the terminal's Flow
3939 dialog ("Steps, 30 days") and the `steps` array of the MCP `flow` tool and
4040 `/api/v1/telemetry/flow` (public repositories alone for an agent).
4141
42 ## Polling `/api/v1/telemetry/flow`
43
44 The answer is a copy computed in the background and at most `FLOW_TTL_MS`
45 (5 s) old, so polling faster than every 5 s only re-reads the same copy. Poll
46 no faster than every 5 s, and send `if-none-match` with the last `etag`: a
47 repeat on the same copy is `304` with no body. The response carries
48 `cache-control: private, max-age=<seconds until the copy is 5 s old>` (wait that
49 long), `vary: authorization, cookie` (the server appends `Accept-Encoding` on the wire), and an `etag` made from the reader kind
50 and the copy's computed-at time (the body is never hashed). A `304` is still
51 counted and still comes after the auth and scope checks. To find a reader that
52 is too fast, look at `forge_telemetry_reads_total{route="flow",reader=...}` on
53 /metrics: its rate (`rate(...[5m])`) above 0.2 per second is faster than 5 s.
54 The MCP `flow` tool is unchanged.
55
4256 ## Rebases
4357
4458 Approved work lands before new work is built, in the check lane too (#6140).
src/http/api.ts +18 −3
@@ -60,7 +60,7 @@ import { receiveMirrorHook } from "../origins/mirror-pulls.ts";
6060 import { receiveBeat, SIGNATURE_HEADER } from "../spawn/heartbeat.ts";
6161 import { takeOrTell } from "../actions/follow.ts";
6262 import { noteRead, telemetryReader, type Reader } from "../actions/telemetry.ts";
63 import { flowCached } from "../system/flow_cache.ts";
63 import { flowCopy, flowEtag, flowMaxAge } from "../system/flow_cache.ts";
6464 import { countRead } from "../system/telemetry_reads.ts";
6565 import { historyFor, sourcesOut, systemFor, welcome } from "../system/telemetry.ts";
6666 import { markReaders } from "../crew/direct.ts";
@@ -1187,7 +1187,7 @@ export function apiRoutes(router: Router<Handler>): void {
11871187 // Open read hooks (#1789): the owner, or an agent holding `telemetry:read`.
11881188 // Anyone else is refused; every agent read is logged.
11891189 const telemetryRead =
1190 (what: string, out: (ctx: Ctx, reader: Reader) => unknown) =>
1190 (what: string, out: (ctx: Ctx, reader: Reader) => unknown | (() => Response)) =>
11911191 (ctx: Ctx): Response => {
11921192 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
11931193 if (!ctx.auth.user) return unauthorized();
@@ -1198,10 +1198,25 @@ export function apiRoutes(router: Router<Handler>): void {
11981198 noteRead(ctx.app, ctx.auth.user, reader, what);
11991199 countRead(what, ctx.auth.user.username);
12001200 const body = timePhase(ctx.request, "read", () => out(ctx, reader));
1201 // A read may hand back a responder (headers, a 304); it runs in the render phase.
1202 if (typeof body === "function") return timePhase(ctx.request, "render", body as () => Response);
12011203 return timePhase(ctx.request, "render", () => json(body));
12021204 };
12031205 router.add("GET", "/api/v1/telemetry/system", telemetryRead("system", (c, r) => systemFor(c.app, r)));
outdated

This duplicates telemetryRead's auth/scope/reader/noteRead/countRead sequence. Extend telemetryRead (or give it a responder hook that can set headers and return 304) so the flow route stays on the one shared path.

1204 router.add("GET", "/api/v1/telemetry/flow", telemetryRead("flow", (c, r) => flowCached(c.app, r)));
1206 // The flow read tells a poller how fresh it is: the etag is the reader kind and
1207 // the copy's computed-at time, so a repeat inside the TTL is a 304 (#9068).
1208 router.add("GET", "/api/v1/telemetry/flow", telemetryRead("flow", (c, reader) => {
1209 const copy = flowCopy(c.app, reader);
1210 const etag = flowEtag(reader, copy.at);
1211 const headers = {
1212 "cache-control": `private, max-age=${flowMaxAge(copy.at)}`,
1213 vary: "authorization, cookie",
1214 etag,
1215 };
1216 const sent = (c.request.headers.get("if-none-match") ?? "").split(",").map((t) => t.trim().replace(/^W\//, ""));
1217 return () =>
1218 sent.includes(etag) || sent.includes("*") ? new Response(null, { status: 304, headers }) : json(copy.body, 200, headers);
1219 }));
12051220 router.add("GET", "/api/v1/telemetry/home", telemetryRead("home", (c) => homeTruth(c.app, c.auth.user!)));
12061221 router.add("GET", "/api/v1/telemetry/sources", telemetryRead("sources", () => ({ sources: sourcesOut() })));
12071222 router.add("GET", "/api/v1/telemetry/history", telemetryRead("history", (c) => ({ points: historyFor(c.app) })));
src/system/flow_cache.ts +18 −3
@@ -103,7 +103,8 @@ export function warmFlow(app: App, deps: FlowDeps = defaults, now: () => number
103103 ).then(() => undefined);
104104 }
105105
106 export function flowCached(app: App, reader: Reader, now = Date.now(), deps: FlowDeps = defaults): unknown {
106 /** A copy with the time it was computed (ms since epoch) — what a poller's validators come from. */
107 export function flowCopy(app: App, reader: Reader, now = Date.now(), deps: FlowDeps = defaults): { body: unknown; at: number } {
107108 const byKey = entries(app);
108109 const key = keyOf(reader);
109110 const hit = byKey.get(key);
@@ -113,10 +114,24 @@ export function flowCached(app: App, reader: Reader, now = Date.now(), deps: Flo
113114 let r = g.next();
114115 while (!r.done) r = g.next();
115116 byKey.set(key, { at: now, body: r.value, refreshing: false });
116 return r.value;
117 return { body: r.value, at: now };
117118 }
118119 if (now - hit.at >= FLOW_TTL_MS && !hit.refreshing) refresh(app, reader, hit, deps, () => now);
119 return hit.body;
120 return { body: hit.body, at: hit.at };
121 }
122
123 export function flowCached(app: App, reader: Reader, now = Date.now(), deps: FlowDeps = defaults): unknown {
124 return flowCopy(app, reader, now, deps).body;
125 }
126
127 /** The validator for a copy: the kind of reader and when it was computed; the body is never hashed. */
128 export function flowEtag(reader: Reader, at: number): string {
129 return `"flow-${keyOf(reader)}-${at}"`;
130 }
131
132 /** Whole seconds until a copy computed at `at` is FLOW_TTL_MS old, never below 0. */
133 export function flowMaxAge(at: number, now = Date.now()): number {
134 return Math.max(0, Math.ceil((at + FLOW_TTL_MS - now) / 1000));
120135 }
121136
122137 /** Drops every copy for `app`, so the next read computes afresh; for a test that changed what flow reads. */
test/flow-cache.test.ts +61 −1
@@ -1,8 +1,14 @@
11 /** The flow answer is computed in the background, in slices, never in a request (#8978). */
22 import { afterAll, beforeAll, expect, test } from "bun:test";
33 import { rmSync } from "node:fs";
4 import { mkdtemp, rm } from "node:fs/promises";
outdated

Merge this with the later mkdtemp import from node:fs/promises, and move all the mid-file imports (lines 112-118) to the top of the file.

5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
47 import type { App } from "../src/app.ts";
5 import { flowCached, warmFlow, FLOW_TTL_MS, type FlowDeps } from "../src/system/flow_cache.ts";
8 import { createApp } from "../src/app.ts";
9 import { serve } from "../src/http/server.ts";
10 import { seedAdmin } from "./helpers/admin.ts";
11 import { flowCached, flowEtag, flowMaxAge, warmFlow, FLOW_TTL_MS, type FlowDeps } from "../src/system/flow_cache.ts";
612 import { countRead, resetTelemetryReads, telemetryReadsFamily } from "../src/system/telemetry_reads.ts";
713 import { flowSlices } from "../src/system/telemetry.ts";
814 import { openMetrics } from "../src/metrics/openmetrics.ts";
@@ -106,3 +112,57 @@ test("readers past the bound are counted as other, not dropped", () => {
106112 const text = openMetrics([telemetryReadsFamily()]);
107113 expect(text).toContain('reader="other",route="flow"} 51');
108114 });
115
116 // --- the validators a poller reads (#9068) ---
outdated

A second import from node:fs/promises and a block of imports mid-file. Hoist and merge them into the top import block.

117
118 test("etag differs by reader kind and by computed-at; max-age counts down to the TTL", () => {
119 expect(flowEtag({ kind: "owner" }, 7)).not.toBe(flowEtag({ kind: "agent" }, 7));
120 expect(flowEtag({ kind: "agent" }, 7)).not.toBe(flowEtag({ kind: "agent", spend: true }, 7));
121 expect(flowEtag(owner, 7)).not.toBe(flowEtag(owner, 8));
122 expect(flowMaxAge(1_000, 1_000)).toBe(5);
123 expect(flowMaxAge(1_000, 1_000 + FLOW_TTL_MS - 1)).toBe(1);
124 expect(flowMaxAge(1_000, 1_000 + FLOW_TTL_MS + 50)).toBe(0);
125 });
126
127 test("the flow route: headers on 200, matching etag is an empty 304, unauthenticated learns nothing", async () => {
128 const dataDir = await mkdtemp(join(tmpdir(), "forge-flow304-"));
129 const app = createApp({ dataDir, port: 0 });
130 const servers = await serve(app);
131 try {
132 await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
133 const minted = await fetch(`${servers.url}/api/v1/tokens`, {
134 method: "POST",
135 headers: { "content-type": "application/json" },
136 body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }),
137 });
138 const auth = { authorization: `Bearer ${((await minted.json()) as { token: string }).token}` };
139 const url = `${servers.url}/api/v1/telemetry/flow`;
140 resetTelemetryReads();
141 const ok = await fetch(url, { headers: auth });
142 await ok.arrayBuffer();
143 expect(ok.status).toBe(200);
144 const etag = ok.headers.get("etag")!;
145 expect(etag).toBeTruthy();
146 expect(ok.headers.get("vary")).toStartWith("authorization, cookie");
147 expect(ok.headers.get("cache-control")).toMatch(/^private, max-age=[0-5]$/);
148 const again = await fetch(url, { headers: { ...auth, "if-none-match": etag } });
149 expect(again.status).toBe(304);
150 expect((await again.arrayBuffer()).byteLength).toBe(0);
151 expect(again.headers.get("etag")).toBe(etag);
152 expect(again.headers.get("x-content-type-options")).toBe("nosniff");
153 expect(again.headers.get("content-security-policy")).toBeTruthy();
154 const other = await fetch(url, { headers: { ...auth, "if-none-match": '"flow-agent-1"' } });
155 await other.arrayBuffer();
156 expect(other.status).toBe(200);
157 const metrics = openMetrics([telemetryReadsFamily()]);
158 expect(metrics).toMatch(/forge_telemetry_reads_total\{reader="alice",route="flow"\} 3/);
159 const anon = await fetch(url, { headers: { "if-none-match": etag } });
160 await anon.arrayBuffer();
161 expect(anon.status).toBe(401);
162 expect(anon.headers.get("etag")).toBeNull();
163 } finally {
164 servers.stop();
165 app.close();
166 await rm(dataDir, { recursive: true, force: true });
167 }
168 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs