feat(FN-4388): complete remaining observability surfaces

Fusion-Task-Id: FN-4388
Fusion-Task-Lineage: 7497a0a1-5edb-4778-b339-118d1fd668f7
This commit is contained in:
Fusion
2026-05-13 22:33:42 -07:00
committed by gsxdsm
parent 819a499d21
commit 2838df2199
11 changed files with 297 additions and 6 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Add cache-hit observability surfaces across Fusion: structured `token-cache-metrics` logs during token persistence, a new `GET /api/agents/:id/token-usage` endpoint with windowed summaries, dashboard cache-hit ratio displays, and a new `pnpm fn:cache-stats` CLI report.

View File

@@ -367,6 +367,14 @@ The **Token Usage** panel in Agents view is derived from each agent's persisted
- `totalInputTokens` - `totalInputTokens`
- `totalOutputTokens` - `totalOutputTokens`
### Cache-hit observability
Fusion exposes cache-hit metrics across logs, API, and CLI:
- **Structured logs:** `token-cache-metrics` channel emits per-persist records with `taskId`, `agentId`, `role`, `inputTokens`, `cachedTokens`, `cacheWriteTokens`, and computed `hitRatio`.
- **Agent API:** `GET /api/agents/:id/token-usage` returns `last24h`, `last7d`, and `allTime` window summaries for permanent agents.
- **CLI rollup:** run `pnpm fn:cache-stats` (or `pnpm fn:cache-stats --json`) for project-wide role totals plus per-permanent-agent cache-hit summaries.
For the current filtered/visible agent set, the panel shows: For the current filtered/visible agent set, the panel shows:
- Aggregate input token total - Aggregate input token total

View File

@@ -28,6 +28,7 @@
"build:exe:all": "pnpm build && pnpm --filter @runfusion/fusion build:exe:all", "build:exe:all": "pnpm build && pnpm --filter @runfusion/fusion build:exe:all",
"test": "node scripts/test-changed.mjs", "test": "node scripts/test-changed.mjs",
"test:scripts": "node --test scripts/__tests__/*.test.mjs", "test:scripts": "node --test scripts/__tests__/*.test.mjs",
"fn:cache-stats": "node scripts/cache-stats.mjs",
"test:full": "node scripts/test-changed.mjs --full --no-cache", "test:full": "node scripts/test-changed.mjs --full --no-cache",
"test:ci:shard": "node scripts/ci-test-shard.mjs", "test:ci:shard": "node scripts/ci-test-shard.mjs",
"test:serial": "FUSION_TEST_CONCURRENCY=1 FUSION_TEST_WORKSPACE_CONCURRENCY=1 pnpm test:full", "test:serial": "FUSION_TEST_CONCURRENCY=1 FUSION_TEST_WORKSPACE_CONCURRENCY=1 pnpm test:full",

View File

@@ -920,6 +920,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
initialRunId={initialRunId} initialRunId={initialRunId}
preferActiveRun={preferActiveRun} preferActiveRun={preferActiveRun}
runNowRefreshToken={runNowRefreshToken} runNowRefreshToken={runNowRefreshToken}
isEphemeral={isEphemeralAgent(agent)}
/> />
)} )}
@@ -1638,6 +1639,21 @@ function MailTab({
// ── Runs Tab ─────────────────────────────────────────────────────────────── // ── Runs Tab ───────────────────────────────────────────────────────────────
interface AgentTokenUsageWindowSummary {
totalInputTokens: number;
totalCachedTokens: number;
totalCacheWriteTokens: number;
totalOutputTokens: number;
nTasks: number;
hitRatio: number;
}
interface AgentTokenUsageSummary {
last24h: AgentTokenUsageWindowSummary;
last7d: AgentTokenUsageWindowSummary;
allTime: AgentTokenUsageWindowSummary;
}
function RunsTab({ function RunsTab({
addToast, addToast,
agentId, agentId,
@@ -1647,6 +1663,7 @@ function RunsTab({
initialRunId, initialRunId,
preferActiveRun, preferActiveRun,
runNowRefreshToken, runNowRefreshToken,
isEphemeral,
}: { }: {
addToast: (msg: string, type?: "success" | "error") => void; addToast: (msg: string, type?: "success" | "error") => void;
agentId: string; agentId: string;
@@ -1656,6 +1673,7 @@ function RunsTab({
initialRunId?: string | null; initialRunId?: string | null;
preferActiveRun?: boolean; preferActiveRun?: boolean;
runNowRefreshToken: number; runNowRefreshToken: number;
isEphemeral: boolean;
}) { }) {
const [runs, setRuns] = useState<AgentHeartbeatRun[]>([]); const [runs, setRuns] = useState<AgentHeartbeatRun[]>([]);
const { confirm } = useConfirm(); const { confirm } = useConfirm();
@@ -1665,6 +1683,7 @@ function RunsTab({
const [isLoadingLogs, setIsLoadingLogs] = useState(false); const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const [detailRun, setDetailRun] = useState<AgentHeartbeatRun | null>(null); const [detailRun, setDetailRun] = useState<AgentHeartbeatRun | null>(null);
const [isLoadingDetail, setIsLoadingDetail] = useState(false); const [isLoadingDetail, setIsLoadingDetail] = useState(false);
const [tokenUsageSummary, setTokenUsageSummary] = useState<AgentTokenUsageSummary | null>(null);
const hasAutoExpandedInitialRunRef = useRef(false); const hasAutoExpandedInitialRunRef = useRef(false);
const didMountRunNowRefreshRef = useRef(false); const didMountRunNowRefreshRef = useRef(false);
@@ -1684,6 +1703,36 @@ function RunsTab({
void loadRuns(); void loadRuns();
}, [loadRuns]); }, [loadRuns]);
useEffect(() => {
if (isEphemeral) {
setTokenUsageSummary(null);
return;
}
const controller = new AbortController();
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
void fetch(`/api/agents/${encodeURIComponent(agentId)}/token-usage${query}`, { signal: controller.signal })
.then(async (res) => {
if (!res.ok) {
if (res.status === 400) {
setTokenUsageSummary(null);
return;
}
throw new Error(`Request failed: ${res.status}`);
}
const data = (await res.json()) as AgentTokenUsageSummary;
setTokenUsageSummary(data);
})
.catch((err) => {
if (err instanceof Error && err.name === "AbortError") {
return;
}
addToast(`Failed to load cache hit ratio: ${getErrorMessage(err)}`, "error");
});
return () => controller.abort();
}, [agentId, projectId, addToast, isEphemeral]);
useEffect(() => { useEffect(() => {
if (!didMountRunNowRefreshRef.current) { if (!didMountRunNowRefreshRef.current) {
didMountRunNowRefreshRef.current = true; didMountRunNowRefreshRef.current = true;
@@ -2044,8 +2093,25 @@ function RunsTab({
); );
}; };
const renderCacheWindow = (label: string, window: AgentTokenUsageWindowSummary) => (
<div className="run-context-item" key={label}>
<span className="text-muted">{label}:</span>{" "}
<span>{(window.hitRatio * 100).toFixed(1)}% ({window.totalCachedTokens.toLocaleString()} / {window.totalCacheWriteTokens.toLocaleString()} / {window.totalInputTokens.toLocaleString()} / {window.nTasks.toLocaleString()})</span>
</div>
);
return ( return (
<div className="runs-tab"> <div className="runs-tab">
{tokenUsageSummary && (
<div className="run-output-section">
<div className="run-output-label">Cache hit ratio</div>
<div className="run-context-grid">
{renderCacheWindow("Last 24h", tokenUsageSummary.last24h)}
{renderCacheWindow("Last 7d", tokenUsageSummary.last7d)}
{renderCacheWindow("All time", tokenUsageSummary.allTime)}
</div>
</div>
)}
<div className="runs-toolbar runs-toolbar--between"> <div className="runs-toolbar runs-toolbar--between">
<span className="runs-toolbar-meta"> <span className="runs-toolbar-meta">
{runs.length} run{runs.length !== 1 ? "s" : ""} {runs.length} run{runs.length !== 1 ? "s" : ""}

View File

@@ -132,6 +132,25 @@
font-size: calc(var(--space-sm) + var(--space-xs)); font-size: calc(var(--space-sm) + var(--space-xs));
} }
.task-token-stats-panel__cache-ratio {
color: var(--text);
font-size: calc(var(--space-sm) + var(--space-xs));
}
.task-token-stats-panel__cache-ratio-label {
color: var(--text-muted);
}
.task-token-stats-panel__cache-ratio-value {
font-family: var(--font-mono);
font-weight: 700;
}
.task-token-stats-panel__cache-breakdown {
color: var(--text-muted);
font-size: calc(var(--space-sm) + var(--space-xs));
}
.task-token-stats-panel__empty, .task-token-stats-panel__empty,
.task-token-stats-panel__loading { .task-token-stats-panel__loading {
border: 1px dashed var(--border); border: 1px dashed var(--border);

View File

@@ -41,6 +41,10 @@ function formatTokenCount(value: number): string {
return value.toLocaleString(); return value.toLocaleString();
} }
function formatHitRatio(ratio: number): string {
return `${(ratio * 100).toFixed(1)}%`;
}
function formatTimestamp(value: string): string { function formatTimestamp(value: string): string {
const parsed = new Date(value); const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) { if (Number.isNaN(parsed.getTime())) {
@@ -265,6 +269,17 @@ export function TaskTokenStatsPanel({ tokenUsage, loading, task }: TaskTokenStat
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.totalTokens)}</span> <span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.totalTokens)}</span>
</div> </div>
</div> </div>
<div className="task-token-stats-panel__cache-ratio">
<span className="task-token-stats-panel__cache-ratio-label">Cache hit ratio:</span>{" "}
<span className="task-token-stats-panel__cache-ratio-value">
{(tokenUsage.inputTokens + tokenUsage.cachedTokens) > 0
? formatHitRatio(tokenUsage.cachedTokens / (tokenUsage.inputTokens + tokenUsage.cachedTokens))
: "—"}
</span>
</div>
<div className="task-token-stats-panel__cache-breakdown">
(read {formatTokenCount(tokenUsage.cachedTokens)} / write {formatTokenCount(tokenUsage.cacheWriteTokens ?? 0)} / input {formatTokenCount(tokenUsage.inputTokens)})
</div>
<dl className="task-token-stats-panel__timestamps"> <dl className="task-token-stats-panel__timestamps">
<div className="task-token-stats-panel__timestamp-row"> <div className="task-token-stats-panel__timestamp-row">
<dt>First used</dt> <dt>First used</dt>

View File

@@ -50,6 +50,17 @@ import { AgentDetailView } from "../AgentDetailView";
describe("AgentDetailView — logs, tasks, and runs", () => { describe("AgentDetailView — logs, tasks, and runs", () => {
beforeEach(() => { beforeEach(() => {
setupAgentDetailMocks(); setupAgentDetailMocks();
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/token-usage")) {
return new Response(JSON.stringify({
last24h: { totalInputTokens: 100, totalCachedTokens: 50, totalCacheWriteTokens: 10, totalOutputTokens: 20, nTasks: 2, hitRatio: 0.3333 },
last7d: { totalInputTokens: 200, totalCachedTokens: 100, totalCacheWriteTokens: 20, totalOutputTokens: 40, nTasks: 3, hitRatio: 0.3333 },
allTime: { totalInputTokens: 300, totalCachedTokens: 150, totalCacheWriteTokens: 30, totalOutputTokens: 60, nTasks: 4, hitRatio: 0.3333 },
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
}));
}); });
describe("Logs tab", () => { describe("Logs tab", () => {
@@ -244,6 +255,36 @@ describe("Tasks tab", () => {
describe("Runs Tab — click to show logs", () => { describe("Runs Tab — click to show logs", () => {
it("shows cache hit ratio section for permanent agents", async () => {
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await user.click(await screen.findByText("Runs"));
await waitFor(() => {
expect(screen.getByText("Cache hit ratio")).toBeInTheDocument();
expect(screen.getByText(/Last 24h:/)).toBeInTheDocument();
expect(screen.getAllByText(/33.3%/)).toHaveLength(3);
});
});
it("hides cache hit ratio section for ephemeral agents", async () => {
const user = userEvent.setup();
mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: { type: "spawned" } as any }));
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/token-usage")) {
return new Response(JSON.stringify({ error: "Token usage is not available for ephemeral agents" }), { status: 400 });
}
return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
}));
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await user.click(await screen.findByText("Runs"));
await waitFor(() => {
expect(screen.queryByText("Cache hit ratio")).not.toBeInTheDocument();
});
});
const navigateToRuns = async (user: ReturnType<typeof userEvent.setup>) => { const navigateToRuns = async (user: ReturnType<typeof userEvent.setup>) => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Runs")).toBeInTheDocument(); expect(screen.getByText("Runs")).toBeInTheDocument();

View File

@@ -96,6 +96,9 @@ describe("TaskTokenStatsPanel", () => {
expect(screen.getByText("210")).toBeInTheDocument(); expect(screen.getByText("210")).toBeInTheDocument();
expect(screen.getByText("15")).toBeInTheDocument(); expect(screen.getByText("15")).toBeInTheDocument();
expect(screen.getByText("1,860")).toBeInTheDocument(); expect(screen.getByText("1,860")).toBeInTheDocument();
expect(screen.getByText("Cache hit ratio:")).toBeInTheDocument();
expect(screen.getByText("14.9%")).toBeInTheDocument();
expect(screen.getByText("(read 210 / write 15 / input 1,200)")).toBeInTheDocument();
const firstUsedTime = screen.getByText((_, element) => element?.tagName === "TIME" && element.getAttribute("datetime") === "2026-04-24T09:00:00.000Z"); const firstUsedTime = screen.getByText((_, element) => element?.tagName === "TIME" && element.getAttribute("datetime") === "2026-04-24T09:00:00.000Z");
const lastUsedTime = screen.getByText((_, element) => element?.tagName === "TIME" && element.getAttribute("datetime") === "2026-04-24T10:15:00.000Z"); const lastUsedTime = screen.getByText((_, element) => element?.tagName === "TIME" && element.getAttribute("datetime") === "2026-04-24T10:15:00.000Z");
@@ -104,6 +107,28 @@ describe("TaskTokenStatsPanel", () => {
expect(lastUsedTime).toBeInTheDocument(); expect(lastUsedTime).toBeInTheDocument();
}); });
it("shows dash cache hit ratio when cache/input denominator is zero", () => {
render(
<TaskTokenStatsPanel
loading={false}
task={makeTask()}
tokenUsage={{
inputTokens: 0,
outputTokens: 12,
cachedTokens: 0,
cacheWriteTokens: 0,
totalTokens: 12,
firstUsedAt: "2026-04-24T09:00:00.000Z",
lastUsedAt: "2026-04-24T10:15:00.000Z",
}}
/>,
);
expect(screen.getByText("Cache hit ratio:")).toBeInTheDocument();
expect(screen.getByText("—")).toBeInTheDocument();
expect(screen.getByText("(read 0 / write 0 / input 0)")).toBeInTheDocument();
});
it("gracefully handles logs without timing patterns", () => { it("gracefully handles logs without timing patterns", () => {
render( render(
<TaskTokenStatsPanel <TaskTokenStatsPanel

View File

@@ -84,12 +84,7 @@ describe("accumulateSessionTokenUsage", () => {
it("emits token-cache-metrics log when executor persists non-zero delta", async () => { it("emits token-cache-metrics log when executor persists non-zero delta", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const store = createStore(undefined); const store = createStore(undefined);
const executor = Object.create(TaskExecutor.prototype) as TaskExecutor & { const executor = Object.create(TaskExecutor.prototype) as any;
store: TaskStore;
tokenUsageBaselines: Map<string, { inputTokens: number; outputTokens: number; cachedTokens: number; cacheWriteTokens: number; totalTokens: number }>;
activeSessions: Map<string, { session: unknown }>;
persistTokenUsage: (taskId: string, session?: unknown) => Promise<void>;
};
executor.store = store; executor.store = store;
executor.tokenUsageBaselines = new Map(); executor.tokenUsageBaselines = new Map();
executor.activeSessions = new Map(); executor.activeSessions = new Map();

View File

@@ -0,0 +1,32 @@
import test from "node:test";
import assert from "node:assert/strict";
import { collectCacheStats } from "../cache-stats.mjs";
test("collectCacheStats groups role and permanent-agent totals", async () => {
const taskStore = {
async listTasks() {
return [
{ assignedAgentId: "a1", tokenUsage: { inputTokens: 100, cachedTokens: 50, cacheWriteTokens: 10, outputTokens: 20 } },
{ assignedAgentId: "a2", tokenUsage: { inputTokens: 200, cachedTokens: 100, cacheWriteTokens: 5, outputTokens: 50 } },
{ assignedAgentId: "missing", tokenUsage: { inputTokens: 10, cachedTokens: 0, cacheWriteTokens: 0, outputTokens: 2 } },
];
},
};
const agentStore = {
async listAgents() {
return [
{ id: "a1", role: "executor", metadata: { type: "permanent" } },
{ id: "a2", role: "reviewer", metadata: { type: "spawned" } },
];
},
};
const result = await collectCacheStats({ taskStore, agentStore });
assert.equal(result.byRole.find((r) => r.role === "executor")?.total_cached, 50);
assert.equal(result.byRole.find((r) => r.role === "reviewer")?.total_input, 200);
assert.equal(result.byRole.find((r) => r.role === "unknown")?.total_input, 10);
assert.equal(result.byAgent.length, 1);
assert.equal(result.byAgent[0].id, "a1");
assert.equal(result.byAgent[0].hit_ratio, 50 / 150);
});

84
scripts/cache-stats.mjs Normal file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env node
function createSummary() {
return { total_input: 0, total_cached: 0, total_cache_write: 0, total_output: 0, n_tasks: 0, hit_ratio: 0 };
}
function applyUsage(summary, usage) {
summary.total_input += usage.inputTokens ?? 0;
summary.total_cached += usage.cachedTokens ?? 0;
summary.total_cache_write += usage.cacheWriteTokens ?? 0;
summary.total_output += usage.outputTokens ?? 0;
summary.n_tasks += 1;
}
function finalizeSummary(summary) {
const denominator = summary.total_input + summary.total_cached;
return { ...summary, hit_ratio: denominator > 0 ? summary.total_cached / denominator : 0 };
}
function printTable(title, rows) {
console.log(`\n${title}`);
console.table(rows);
}
export async function collectCacheStats({ taskStore, agentStore }) {
const tasks = await taskStore.listTasks({ includeArchived: true, slim: true });
const agents = await agentStore.listAgents({ includeEphemeral: true });
const agentById = new Map(agents.map((agent) => [agent.id, agent]));
const roleSummaries = new Map();
const agentSummaries = new Map();
for (const task of tasks) {
if (!task.tokenUsage) continue;
const ownerId = task.assignedAgentId ?? task.sourceAgentId ?? task.checkedOutBy;
const owner = ownerId ? agentById.get(ownerId) : undefined;
const role = owner?.role ?? "unknown";
if (!roleSummaries.has(role)) roleSummaries.set(role, createSummary());
applyUsage(roleSummaries.get(role), task.tokenUsage);
if (owner && owner.metadata?.type !== "spawned") {
if (!agentSummaries.has(owner.id)) agentSummaries.set(owner.id, { id: owner.id, role: owner.role, ...createSummary() });
applyUsage(agentSummaries.get(owner.id), task.tokenUsage);
}
}
const byRole = Array.from(roleSummaries.entries()).map(([role, summary]) => ({ role, ...finalizeSummary(summary) }));
const byAgent = Array.from(agentSummaries.values()).map((summary) => ({ ...summary, ...finalizeSummary(summary) }));
return { byRole, byAgent };
}
export async function main(argv = process.argv.slice(2), deps = {}) {
const asJson = argv.includes("--json");
const projectDir = process.cwd();
const { taskStore, agentStore } = deps.stores ?? (await (async () => {
const { TaskStore, AgentStore } = await import("../packages/core/dist/index.js");
const store = new TaskStore(projectDir);
await store.init();
const aStore = new AgentStore({ rootDir: store.getFusionDir() });
await aStore.init();
return { taskStore: store, agentStore: aStore };
})());
const result = await collectCacheStats({ taskStore, agentStore });
if (asJson) {
console.log(JSON.stringify(result, null, 2));
return 0;
}
printTable("Cache stats by role", result.byRole);
printTable("Cache stats by permanent agent", result.byAgent);
return 0;
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().then((code) => {
process.exitCode = code;
}).catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}