Files
Mike Schwarz 0ee9d100f7 feat: add bounded telemetry metadata reads and CLI (#696)
Add bounded, versioned metadata reads for telemetry consumers: GET /api/telemetry/v1/events, /queue-transitions and /nodes/:id/tenures, with rig telemetry events|transitions|tenures as single-page CLI readers. Each read bounds the rows and bytes it touches before filtering, selects explicit metadata columns only (no event payloads, notes, queue bodies, commands or environment), uses fixed-window opaque cursors, and names gaps instead of reporting missing history as empty: deleted or retained-floor history, a daemon restart, and withheld oversized fields. No schema change, writer, poller or subscription. docs/reference/telemetry.md describes the contract, including that a daemon restart invalidates a following cursor.
2026-10-04 03:35:04 -07:00

93 lines
5.6 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { Command } from "commander";
import Database from "better-sqlite3";
import { Hono } from "hono";
import { telemetryRoutes } from "../../daemon/src/routes/telemetry.js";
import { eventsSchema } from "../../daemon/src/db/migrations/003_events.js";
import { telemetryCommand } from "../src/commands/telemetry.js";
import { STATE_FILE } from "../src/daemon-lifecycle.js";
import type { StatusDeps } from "../src/commands/status.js";
const databases: Database.Database[] = [];
afterEach(() => { vi.restoreAllMocks(); for (const db of databases.splice(0))
db.close(); });
async function run(args: string[], respond: (path: string) => Promise<{
status: number;
data: unknown;
}>) {
const paths: string[] = [];
const logs: string[] = [];
const errors: string[] = [];
const saved = process.exitCode;
vi.spyOn(console, "log").mockImplementation((...a) => { logs.push(a.join(" ")); });
vi.spyOn(console, "error").mockImplementation((...a) => { errors.push(a.join(" ")); });
const deps = {
lifecycleDeps: {
readFile: (path: string) => path === STATE_FILE ? JSON.stringify({ pid: 123, port: 17433, db: "fixture.sqlite", startedAt: "2026-10-04T09:00:00Z" }) : null,
exists: (path: string) => path === STATE_FILE, isProcessAlive: () => true, fetch: async () => ({ ok: true }),
},
clientFactory: () => ({ get: async (path: string) => { paths.push(path); return respond(path); } }),
} as unknown as StatusDeps;
process.exitCode = undefined;
try {
await new Command().addCommand(telemetryCommand(deps)).parseAsync(["node", "rig", "telemetry", ...args]);
return { paths, logs, errors, exit: process.exitCode };
}
finally {
process.exitCode = saved;
}
}
const envelope = {
schemaVersion: 1, stream: "events", source: { bootEpoch: "boot", sequenceSpaceId: null },
rows: [{ seq: "9007199254740993" }],
page: { scanned: 128, returned: 1, through: "9007199254741000", hasMore: true, nextCursor: "next-token", restartCursor: null },
coverage: { status: "partial", historyCompleteness: "unknown", gaps: [{ code: "retained_sequence_gap" }], gapCount: 1 },
};
describe("rig telemetry", () => {
it("passes filters and cursor to one read and preserves the complete JSON envelope", async () => {
const result = await run(["events", "--cursor", "cursor-token", "--limit", "17", "--node", "node-a", "--rig", "rig-a", "--json"], async () => ({ status: 200, data: envelope }));
expect(result.paths).toHaveLength(1);
const url = new URL(result.paths[0]!, "http://fixture");
expect(url.pathname).toBe("/api/telemetry/v1/events");
expect(Object.fromEntries(url.searchParams)).toEqual({ cursor: "cursor-token", limit: "17", nodeId: "node-a", rigId: "rig-a" });
expect(JSON.parse(result.logs.join("\n"))).toEqual(envelope);
expect(result.exit).toBeUndefined();
});
it("prints gaps and continuation explicitly, without draining another page", async () => {
const result = await run(["events", "--start", "retained"], async () => ({ status: 200, data: envelope }));
expect(result.paths).toHaveLength(1);
expect(result.logs.join("\n")).toContain("Gap: retained_sequence_gap");
expect(result.logs.join("\n")).toContain("Next cursor: next-token");
expect(result.logs.join("\n")).toContain("this command read one page");
});
it("maps transition and tenure grammar without importing private database state", async () => {
const transitions = await run(["transitions", "--start", "retained", "--qitem", "q-a", "--json"], async () => ({ status: 200, data: envelope }));
expect(transitions.paths).toEqual(["/api/telemetry/v1/queue-transitions?start=retained&qitemId=q-a"]);
const tenures = await run(["tenures", "node/a", "--limit", "32", "--json"], async () => ({ status: 200, data: envelope }));
expect(tenures.paths).toEqual(["/api/telemetry/v1/nodes/node%2Fa/tenures?limit=32"]);
});
it.each([400, 503])("retains the HTTP %i error and nonzero exit", async (status) => {
const body = { code: "telemetry_read_unavailable", error: "Unavailable" };
const result = await run(["events", "--json"], async () => ({ status, data: body }));
expect(result.exit).toBe(status === 400 ? 1 : 2);
expect(JSON.parse(result.logs.join("\n"))).toEqual(body);
});
it("feeds actual Commander arguments through actual Hono/SQLite with the same bounded response", async () => {
const db = new Database(":memory:");
databases.push(db);
db.exec(eventsSchema.sql);
const insert = db.prepare("INSERT INTO events(node_id,type,payload) VALUES ('node-a','node.started','private')");
db.transaction(() => { for (let i = 0; i < 5; i++)
insert.run(); })();
const app = new Hono().route("/api/telemetry", telemetryRoutes({ db: () => db, source: { hostId: "fixture", bootEpoch: "boot" } }));
const result = await run(["events", "--start", "retained", "--limit", "2", "--node", "unmatched", "--json"], async (path) => {
const response = await app.request(path);
return { status: response.status, data: await response.json() };
});
expect(result.paths).toHaveLength(1);
const body = JSON.parse(result.logs.join("\n"));
expect(body.rows).toEqual([]);
expect(body.page).toMatchObject({ scanned: 2, filtered: 2, lastScanned: "2", through: "5", hasMore: true });
expect(body.coverage.historyCompleteness).toBe("unknown");
});
});