Public: anyone can read this; the crew can change it.
An agent reads who polls the telemetry and how fast through the MCP system tool and GET /api/v1/telemetry/system, as /system shows #9095
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/9088-an-agent-reads-who-polls-the-telemetry-a → main 2 commits fork
6 files
changed +96
−7
docs/features/telemetry-readers.md +1
| @@ -7,3 +7,4 @@ Something polling `/api/v1/telemetry` too often shows up here. The owner's `/sys | ||
| 7 | 7 | - **The 5 s rule.** A reader on `flow` whose mean interval is under 5 s is flagged ("faster than the flow copy changes" on the page, `TOO FAST` in the terminal): the flow view does not change that often. |
| 8 | 8 | - **The 200-name cap.** At most 200 route/reader pairs are tracked; a new pair beyond that is counted under reader `other`, so no read goes uncounted. |
| 9 | 9 | - **Not changed.** `/metrics` keeps `forge_telemetry_reads_total{route,reader}`; the window is the page's and terminal's alone. The terminal lists the top six rows and says how many more. |
| 10 | - **Agents.** The same rows (`route`, `reader`, `reads`, `meanIntervalMs`, `tooFast`, most reads first) come as `readers` in the MCP `system` tool and in `GET /api/v1/telemetry/system`, under the same gate as the rest of that answer: a forge admin, or an agent granted `telemetry:read`. A read is counted after its answer is built, so it is not in its own `readers`. |
src/contract/openapi/routes/getApiV1TelemetrySystem.ts +1 −1
| @@ -1,3 +1,3 @@ | ||
| 1 | 1 | import type { Route } from "../shared.ts"; |
| 2 | 2 | |
| 3 | export const route: Route = { method: "get", path: "/api/v1/telemetry/system", summary: "Read the forge's system view; the owner or an agent holding telemetry:read, with a ro token; every agent read is logged", auth: true }; | |
| 3 | export const route: Route = { method: "get", path: "/api/v1/telemetry/system", summary: "Read the forge's system view, with `readers` (who polls the telemetry and how fast); the owner or an agent holding telemetry:read, with a ro token; every agent read is logged", auth: true }; |
src/http/api.ts +4 −3
| @@ -62,7 +62,7 @@ import { takeOrTell } from "../actions/follow.ts"; | ||
| 62 | 62 | import { noteRead, telemetryReader, type Reader } from "../actions/telemetry.ts"; |
| 63 | 63 | import { flowCopy, flowEtag, flowMaxAge } from "../system/flow_cache.ts"; |
| 64 | 64 | import { countRead } from "../system/telemetry_reads.ts"; |
| 65 | import { historyFor, sourcesOut, systemFor, welcome } from "../system/telemetry.ts"; | |
| 65 | import { historyFor, sourcesOut, systemWithReaders, welcome } from "../system/telemetry.ts"; | |
| 66 | 66 | import { markReaders } from "../crew/direct.ts"; |
| 67 | 67 | import { |
| 68 | 68 | addCommentThrough, |
| @@ -1197,13 +1197,14 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1197 | 1197 | const reader = telemetryReader(ctx.app, ctx.auth.user); |
| 1198 | 1198 | if (!reader) return forbidden(); |
| 1199 | 1199 | noteRead(ctx.app, ctx.auth.user, reader, what); |
| 1200 | countRead(what, ctx.auth.user.username); | |
| 1200 | // Counted after the body, so a body that lists the readers does not count this read. | |
| 1201 | 1201 | const body = timePhase(ctx.request, "read", () => out(ctx, reader)); |
| 1202 | countRead(what, ctx.auth.user.username); | |
| 1202 | 1203 | // A read may hand back a responder (headers, a 304); it runs in the render phase. |
| 1203 | 1204 | if (typeof body === "function") return timePhase(ctx.request, "render", body as () => Response); |
| 1204 | 1205 | return timePhase(ctx.request, "render", () => json(body)); |
| 1205 | 1206 | }; |
| 1206 | router.add("GET", "/api/v1/telemetry/system", telemetryRead("system", (c, r) => systemFor(c.app, r))); | |
| 1207 | router.add("GET", "/api/v1/telemetry/system", telemetryRead("system", (c, r) => systemWithReaders(c.app, r))); | |
| 1207 | 1208 | // The flow read tells a poller how fresh it is: the etag is the reader kind and |
| 1208 | 1209 | // the copy's computed-at time, so a repeat inside the TTL is a 304 (#9068). |
| 1209 | 1210 | router.add("GET", "/api/v1/telemetry/flow", telemetryRead("flow", (c, reader) => { |
src/http/mcp/tools/system.ts +6 −3
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | import { noteRead, telemetryReader } from "../../../actions/telemetry.ts"; |
| 2 | import { systemFor } from "../../../system/telemetry.ts"; | |
| 2 | import { systemWithReaders } from "../../../system/telemetry.ts"; | |
| 3 | import { countRead } from "../../../system/telemetry_reads.ts"; | |
| 3 | 4 | import { good, bad, type Tool } from "../shared.ts"; |
| 4 | 5 | |
| 5 | 6 | export const tool: Tool = { |
| 6 | 7 | name: "system", |
| 7 | doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes; a forge admin, or an agent granted `telemetry:read`", | |
| 8 | doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes, and `readers`: who polls the telemetry and how fast; a forge admin, or an agent granted `telemetry:read`", | |
| 8 | 9 | description: |
| 9 | 10 | "The forge machine now: CPU, memory, disk, network. Admin or telemetry:read.", |
| 10 | 11 | inputSchema: { type: "object", properties: {} }, |
| @@ -14,6 +15,8 @@ export const tool: Tool = { | ||
| 14 | 15 | if (!reader) |
| 15 | 16 | return bad("forbidden", "only an admin of this forge, or an agent granted telemetry:read, reads its machine"); |
| 16 | 17 | noteRead(ctx.app, user, reader, "system"); |
| 17 | return good(systemFor(ctx.app, reader)); | |
| 18 | const out = systemWithReaders(ctx.app, reader); | |
| 19 | countRead("system", user.username); | |
| 20 | return good(out); | |
| 18 | 21 | }, |
| 19 | 22 | }; |
src/system/telemetry.ts +10
| @@ -26,12 +26,22 @@ import { readVerified, verifiedSays } from "../flow/verified.ts"; | ||
| 26 | 26 | import type { Reader } from "../actions/telemetry.ts"; |
| 27 | 27 | import { agentView, live } from "./live.ts"; |
| 28 | 28 | import type { SystemView } from "./view.ts"; |
| 29 | import { readerPaces, type ReaderPace } from "./telemetry_reads.ts"; | |
| 29 | 30 | |
| 30 | 31 | export function systemFor(app: App, reader: Reader): SystemView { |
| 31 | 32 | const view = live().read(); |
| 32 | 33 | return reader.kind === "agent" ? agentView(app, view) : view; |
| 33 | 34 | } |
| 34 | 35 | |
| 36 | /** | |
| 37 | * The system answer for the API and the MCP tool: the view plus `readers`, the | |
| 38 | * rows /system draws (#9088). Names are accounts, so only a telemetry reader gets | |
| 39 | * here. Call it before `countRead` so the read does not count into its own answer. | |
| 40 | */ | |
| 41 | export function systemWithReaders(app: App, reader: Reader): SystemView & { readers: ReaderPace[] } { | |
| 42 | return { ...systemFor(app, reader), readers: readerPaces() }; | |
| 43 | } | |
| 44 | ||
| 35 | 45 | /** |
| 36 | 46 | * The pipeline answer as a generator that yields between its heavy reads, so a |
| 37 | 47 | * caller may run each slice on its own turn of the loop (#8978); `flowFor` |
test/telemetry-reads-readers.test.ts added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | /** `readers` on the MCP system tool and GET /api/v1/telemetry/system (#9088): the rows /system draws, behind the telemetry gate. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createAgent } from "../src/actions/crew.ts"; | |
| 8 | import { setTelemetryGrant } from "../src/actions/telemetry.ts"; | |
| 9 | import { createToken } from "../src/core/tokens.ts"; | |
| 10 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { countRead, resetTelemetryReads } from "../src/system/telemetry_reads.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | let dir = "", app: App, servers: Servers, owner = "", granted = "", plain = ""; | |
| 17 | ||
| 18 | beforeAll(async () => { | |
| 19 | dir = await mkdtemp(join(tmpdir(), "forge-readers-")); | |
| 20 | app = createApp({ dataDir: dir, port: 0 }); | |
| 21 | servers = await serve(app); | |
| 22 | const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-telemetry-passphrase", isAdmin: true }); | |
| 23 | if (!a.ok) throw new Error("alice"); | |
| 24 | owner = createToken(app.db, a.value.id, "t", "admin").token; | |
| 25 | const alice = getUserByName(app.db, "alice")!; | |
| 26 | for (const name of ["bot", "plain"]) { | |
| 27 | const b = await createAgent(app, alice, { username: name }); | |
| 28 | if (!b.ok) throw new Error(name); | |
| 29 | const t = createToken(app.db, b.value.user.id, "t", "ro").token; | |
| 30 | if (name === "bot") granted = t; else plain = t; | |
| 31 | } | |
| 32 | setTelemetryGrant(app, alice, "bot", true); | |
| 33 | }, 30_000); | |
| 34 | afterAll(async () => { servers.stop(); app.close(); await rm(dir, { recursive: true, force: true }); }); | |
| 35 | ||
| 36 | const api = async (token: string) => { | |
| 37 | const r = await fetch(`${servers.url}/api/v1/telemetry/system`, { headers: { authorization: `Bearer ${token}` } }); | |
| 38 | return { status: r.status, body: r.status === 200 ? ((await r.json()) as any) : await r.text() }; | |
| 39 | }; | |
| 40 | const mcp = async (token: string) => { | |
| 41 | const r = await fetch(`${servers.url}/mcp`, { | |
| 42 | method: "POST", | |
| 43 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 44 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "system", arguments: {} } }), | |
| 45 | }); | |
| 46 | const b = (await r.json()) as any; | |
| 47 | return b.error || b.result.isError ? { error: true, text: JSON.stringify(b) } : { error: false, body: JSON.parse(b.result.content[0].text) }; | |
| 48 | }; | |
| 49 | ||
| 50 | test("API and MCP carry the same readers rows, flagging a fast flow reader, without the read itself", async () => { | |
| 51 | resetTelemetryReads(); | |
| 52 | const now = Date.now(); | |
| 53 | for (let i = 0; i < 6; i++) countRead("flow", "poller", now - 20_000 + i * 1000); | |
| 54 | const viaApi = await api(granted); | |
| 55 | expect(viaApi.status).toBe(200); | |
| 56 | expect(viaApi.body.readers).toEqual([{ route: "flow", reader: "poller", reads: 6, meanIntervalMs: 1000, tooFast: true }]); | |
| 57 | const viaMcp = await mcp(granted); | |
| 58 | expect(viaMcp.error).toBe(false); | |
| 59 | // The API read above is counted by now (route system, reader bot); the MCP answer lists it, not itself. | |
| 60 | const rows = viaMcp.body.readers; | |
| 61 | expect(rows[0]).toEqual(viaApi.body.readers[0]); | |
| 62 | expect(rows.find((r: any) => r.route === "system" && r.reader === "bot").reads).toBe(1); | |
| 63 | expect((await api(owner)).body.readers.find((r: any) => r.route === "system" && r.reader === "bot").reads).toBe(2); | |
| 64 | }); | |
| 65 | ||
| 66 | test("an agent without the grant gets the refusal and no reader names", async () => { | |
| 67 | countRead("flow", "secret-reader-name"); | |
| 68 | const r = await api(plain); | |
| 69 | expect(r.status).toBe(403); | |
| 70 | expect(r.body).not.toContain("secret-reader-name"); | |
| 71 | const m = await mcp(plain); | |
| 72 | expect(m.error).toBe(true); | |
| 73 | expect(m.text).not.toContain("secret-reader-name"); | |
| 74 | }); |
new above
offline