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:
5
.changeset/fn-4388-cache-hit-observability.md
Normal file
5
.changeset/fn-4388-cache-hit-observability.md
Normal 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.
|
||||
@@ -367,6 +367,14 @@ The **Token Usage** panel in Agents view is derived from each agent's persisted
|
||||
- `totalInputTokens`
|
||||
- `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:
|
||||
|
||||
- Aggregate input token total
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"build:exe:all": "pnpm build && pnpm --filter @runfusion/fusion build:exe:all",
|
||||
"test": "node scripts/test-changed.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:ci:shard": "node scripts/ci-test-shard.mjs",
|
||||
"test:serial": "FUSION_TEST_CONCURRENCY=1 FUSION_TEST_WORKSPACE_CONCURRENCY=1 pnpm test:full",
|
||||
|
||||
@@ -920,6 +920,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
initialRunId={initialRunId}
|
||||
preferActiveRun={preferActiveRun}
|
||||
runNowRefreshToken={runNowRefreshToken}
|
||||
isEphemeral={isEphemeralAgent(agent)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1638,6 +1639,21 @@ function MailTab({
|
||||
|
||||
// ── 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({
|
||||
addToast,
|
||||
agentId,
|
||||
@@ -1647,6 +1663,7 @@ function RunsTab({
|
||||
initialRunId,
|
||||
preferActiveRun,
|
||||
runNowRefreshToken,
|
||||
isEphemeral,
|
||||
}: {
|
||||
addToast: (msg: string, type?: "success" | "error") => void;
|
||||
agentId: string;
|
||||
@@ -1656,6 +1673,7 @@ function RunsTab({
|
||||
initialRunId?: string | null;
|
||||
preferActiveRun?: boolean;
|
||||
runNowRefreshToken: number;
|
||||
isEphemeral: boolean;
|
||||
}) {
|
||||
const [runs, setRuns] = useState<AgentHeartbeatRun[]>([]);
|
||||
const { confirm } = useConfirm();
|
||||
@@ -1665,6 +1683,7 @@ function RunsTab({
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||
const [detailRun, setDetailRun] = useState<AgentHeartbeatRun | null>(null);
|
||||
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
|
||||
const [tokenUsageSummary, setTokenUsageSummary] = useState<AgentTokenUsageSummary | null>(null);
|
||||
const hasAutoExpandedInitialRunRef = useRef(false);
|
||||
const didMountRunNowRefreshRef = useRef(false);
|
||||
|
||||
@@ -1684,6 +1703,36 @@ function RunsTab({
|
||||
void 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(() => {
|
||||
if (!didMountRunNowRefreshRef.current) {
|
||||
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 (
|
||||
<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">
|
||||
<span className="runs-toolbar-meta">
|
||||
{runs.length} run{runs.length !== 1 ? "s" : ""}
|
||||
|
||||
@@ -132,6 +132,25 @@
|
||||
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__loading {
|
||||
border: 1px dashed var(--border);
|
||||
|
||||
@@ -41,6 +41,10 @@ function formatTokenCount(value: number): string {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
function formatHitRatio(ratio: number): string {
|
||||
return `${(ratio * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string): string {
|
||||
const parsed = new Date(value);
|
||||
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>
|
||||
</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">
|
||||
<div className="task-token-stats-panel__timestamp-row">
|
||||
<dt>First used</dt>
|
||||
|
||||
@@ -50,6 +50,17 @@ import { AgentDetailView } from "../AgentDetailView";
|
||||
describe("AgentDetailView — logs, tasks, and runs", () => {
|
||||
beforeEach(() => {
|
||||
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", () => {
|
||||
@@ -244,6 +255,36 @@ describe("Tasks tab", () => {
|
||||
|
||||
|
||||
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>) => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Runs")).toBeInTheDocument();
|
||||
|
||||
@@ -96,6 +96,9 @@ describe("TaskTokenStatsPanel", () => {
|
||||
expect(screen.getByText("210")).toBeInTheDocument();
|
||||
expect(screen.getByText("15")).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 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();
|
||||
});
|
||||
|
||||
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", () => {
|
||||
render(
|
||||
<TaskTokenStatsPanel
|
||||
|
||||
@@ -84,12 +84,7 @@ describe("accumulateSessionTokenUsage", () => {
|
||||
it("emits token-cache-metrics log when executor persists non-zero delta", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const store = createStore(undefined);
|
||||
const executor = Object.create(TaskExecutor.prototype) as TaskExecutor & {
|
||||
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>;
|
||||
};
|
||||
const executor = Object.create(TaskExecutor.prototype) as any;
|
||||
executor.store = store;
|
||||
executor.tokenUsageBaselines = new Map();
|
||||
executor.activeSessions = new Map();
|
||||
|
||||
32
scripts/__tests__/cache-stats.test.mjs
Normal file
32
scripts/__tests__/cache-stats.test.mjs
Normal 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
84
scripts/cache-stats.mjs
Normal 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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user