feat(telemetry): U1 — queryable usage_events table + emitUsageEvent capture

Schema migration 117→118 adds usage_events; events captured via a dedicated
emitUsageEvent seam wired through AgentLogger tool hooks + executor session
context (model/provider/nodeId), not by widening log signatures. meta is
size-capped and carries only non-sensitive descriptors.
This commit is contained in:
gsxdsm
2026-06-15 19:20:38 -07:00
parent 8051e89e74
commit ab9fdc4136
18 changed files with 801 additions and 52 deletions

View File

@@ -715,7 +715,7 @@ describe("schema migration", () => {
const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null };
expect(row.deletedAt).toBeNull();
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
db.close();
});
@@ -748,7 +748,7 @@ describe("schema migration", () => {
{ id: "WS-001", mode: "prompt", gateMode: "advisory" },
{ id: "WS-002", mode: "script", gateMode: "advisory" },
]);
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
db.close();
});
@@ -798,7 +798,7 @@ describe("schema migration", () => {
reviewerContextRetryCount: 0,
reviewerFallbackRetryCount: 0,
});
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
db.close();
});
@@ -827,7 +827,7 @@ describe("schema migration", () => {
const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("acceptanceCriteria");
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
db.close();
});
@@ -868,7 +868,7 @@ describe("schema migration", () => {
const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>;
expect(missionColumns.map((column) => column.name)).toContain("autoMerge");
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
db.close();
});
@@ -902,7 +902,7 @@ describe("schema migration", () => {
{ id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" },
{ id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" },
]);
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
db.close();
});
@@ -939,7 +939,7 @@ describe("schema migration", () => {
const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>;
expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true);
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
db.close();
});
@@ -1000,7 +1000,7 @@ describe("schema migration", () => {
expect(customFieldsColumn).toBeDefined();
expect(customFieldsColumn?.dflt_value).toBe("'{}'");
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
db.close();
});
@@ -1038,7 +1038,7 @@ describe("schema migration", () => {
const indexes = db.prepare("PRAGMA index_list(workflow_settings)").all() as Array<{ name: string }>;
expect(indexes.some((index) => index.name === "idx_workflow_settings_project")).toBe(true);
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
db.close();
});
@@ -1120,7 +1120,7 @@ describe("schema migration", () => {
expect(indexNames).toContain("idx_cli_sessions_chatSessionId");
expect(indexNames).toContain("idx_cli_sessions_project_state");
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
db.close();
});
@@ -1152,7 +1152,7 @@ describe("schema migration", () => {
.all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("cliExecutorAdapterId");
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
db.close();
});
@@ -1162,7 +1162,7 @@ describe("schema migration", () => {
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
expect(tables.map((row) => row.name)).toContain("cli_sessions");
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
db.close();
});
@@ -1219,20 +1219,20 @@ describe("schema migration", () => {
.get() as { migrated_fragment_id: string | null };
expect(stepRow.migrated_fragment_id).toBeNull();
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
db.close();
});
it("migration 109 is idempotent on re-init", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
db.close();
// Re-open the same on-disk DB: already at 109, the 109 block must be a no-op.
const reopened = new Database(fusionDir);
reopened.init();
expect(reopened.getSchemaVersion()).toBe(117);
expect(reopened.getSchemaVersion()).toBe(118);
const workflowColumns = reopened.prepare("PRAGMA table_info(workflows)").all() as Array<{ name: string }>;
expect(workflowColumns.filter((c) => c.name === "kind")).toHaveLength(1);
const stepColumns = reopened.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>;

View File

@@ -334,7 +334,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
});
it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => {
@@ -393,7 +393,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
});
it("does not overwrite existing config on re-init", () => {
// Update the config
@@ -1463,7 +1463,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1488,15 +1488,15 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
db.close();
});
@@ -1531,7 +1531,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -1572,7 +1572,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1644,7 +1644,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1884,7 +1884,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1958,7 +1958,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -1982,7 +1982,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -2086,7 +2086,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -2305,7 +2305,7 @@ describe("schema migrations", () => {
localDb.init();
expect(localDb.getSchemaVersion()).toBe(117);
expect(localDb.getSchemaVersion()).toBe(118);
const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
@@ -2616,7 +2616,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();
@@ -2770,7 +2770,7 @@ describe("migration v77 task token budget columns", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(117);
expect(migrated.getSchemaVersion()).toBe(118);
const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const names = new Set(rows.map((row) => row.name));
expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true);
@@ -2801,7 +2801,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
const fresh = new Database(fusion);
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(117);
expect(fresh.getSchemaVersion()).toBe(118);
const names = new Set(
(fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
);
@@ -2829,7 +2829,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(117);
expect(migrated.getSchemaVersion()).toBe(118);
const names = new Set(
(migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
);
@@ -2855,7 +2855,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
const fresh = new Database(fusion);
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(117);
expect(fresh.getSchemaVersion()).toBe(118);
const table = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
.get() as { name: string } | undefined;
@@ -2889,7 +2889,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(117);
expect(migrated.getSchemaVersion()).toBe(118);
const table = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
.get() as { name: string } | undefined;
@@ -2930,7 +2930,7 @@ describe("migration v67 drops orphan project auth tables", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(117);
expect(migrated.getSchemaVersion()).toBe(118);
const tables = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;
@@ -2957,7 +2957,7 @@ describe("migration v67 drops orphan project auth tables", () => {
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(117);
expect(fresh.getSchemaVersion()).toBe(118);
const tables = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;

View File

@@ -91,6 +91,6 @@ describe("goals schema", () => {
});
it("reports schema version 101", () => {
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
});
});

View File

@@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(117);
expect(db1.getSchemaVersion()).toBe(118);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(117);
expect(db3.getSchemaVersion()).toBe(118);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(117);
expect(db1.getSchemaVersion()).toBe(118);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(117);
expect(db2.getSchemaVersion()).toBe(118);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });
@@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh DB and run migrations
const db1 = createDatabase(compatDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(117);
expect(db1.getSchemaVersion()).toBe(118);
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
// table without them. This simulates a DB that was created before the

View File

@@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => {
.all() as Array<{ name: string }>;
expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]);
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
});
it("upserts merge request records", async () => {

View File

@@ -3746,7 +3746,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 101 after migration", () => {
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
});
it("mission_features table has loop state columns", () => {

View File

@@ -583,8 +583,8 @@ describe("Run Audit", () => {
expect(indexNames).toContain("idxRunAuditEventsTimestamp");
});
it("schema version is bumped to 117", () => {
expect(db.getSchemaVersion()).toBe(117);
it("schema version is bumped to 118", () => {
expect(db.getSchemaVersion()).toBe(118);
});
});
});

View File

@@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => {
expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]),
);
expect(store.getDatabase().getSchemaVersion()).toBe(117);
expect(store.getDatabase().getSchemaVersion()).toBe(118);
});
it("migrates a legacy v88 database and preserves task rows", async () => {

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
const index = db
.prepare(

View File

@@ -0,0 +1,204 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { Database, SCHEMA_VERSION } from "../db.js";
import {
emitUsageEvent,
queryUsageEvents,
countUsageEventsBy,
categorizeToolName,
USAGE_EVENT_META_MAX_BYTES,
} from "../usage-events.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-usage-events-test-"));
}
describe("usage_events", () => {
let tmpDir: string;
let fusionDir: string;
let db: Database;
beforeEach(() => {
tmpDir = makeTmpDir();
fusionDir = join(tmpDir, ".fusion");
db = new Database(fusionDir);
db.init();
});
afterEach(async () => {
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
it("creates usage_events table with expected columns on fresh init", () => {
const columns = db.prepare("PRAGMA table_info(usage_events)").all() as Array<{ name: string }>;
expect(columns.map((c) => c.name)).toEqual([
"id",
"ts",
"kind",
"taskId",
"agentId",
"nodeId",
"model",
"provider",
"toolName",
"category",
"meta",
]);
});
it("creates the ts/taskId/agentId indexes on fresh init", () => {
const indexes = (
db
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='usage_events'")
.all() as Array<{ name: string }>
).map((r) => r.name);
expect(indexes).toContain("idxUsageEventsTs");
expect(indexes).toContain("idxUsageEventsTaskId");
expect(indexes).toContain("idxUsageEventsAgentId");
});
it("inserts one row for a tool_call event with correct category", () => {
const ok = emitUsageEvent(db, {
kind: "tool_call",
taskId: "T-1",
agentId: "A-1",
nodeId: "node-1",
model: "claude-sonnet-4-5",
provider: "anthropic",
toolName: "Read",
});
expect(ok).toBe(true);
const rows = queryUsageEvents(db, { taskId: "T-1" });
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
kind: "tool_call",
taskId: "T-1",
agentId: "A-1",
nodeId: "node-1",
model: "claude-sonnet-4-5",
provider: "anthropic",
toolName: "Read",
});
});
it("categorizes tool names into coarse buckets", () => {
expect(categorizeToolName("Read")).toBe("read");
expect(categorizeToolName("Grep")).toBe("read");
expect(categorizeToolName("Edit")).toBe("edit");
expect(categorizeToolName("Write")).toBe("edit");
expect(categorizeToolName("Bash")).toBe("execute");
expect(categorizeToolName("WebFetch")).toBe("network");
expect(categorizeToolName("Unknown")).toBe("other");
expect(categorizeToolName(undefined)).toBe("other");
expect(categorizeToolName(null)).toBe("other");
});
it("rejects a meta payload over the byte cap at write (event skipped, nothing inserted)", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const huge = "x".repeat(USAGE_EVENT_META_MAX_BYTES + 100);
const ok = emitUsageEvent(db, {
kind: "tool_error",
taskId: "T-cap",
meta: { blob: huge },
});
expect(ok).toBe(false);
expect(queryUsageEvents(db, { taskId: "T-cap" })).toHaveLength(0);
warn.mockRestore();
});
it("never lets tool-argument content land in meta (caller controls meta; arg helpers are not stored)", () => {
// The write helper only persists what the caller puts in `meta`. A caller
// that follows the contract (descriptors only) leaves no tool args behind.
emitUsageEvent(db, {
kind: "tool_call",
taskId: "T-safe",
toolName: "Bash",
category: "execute",
meta: { durationMs: 12 },
});
const rows = queryUsageEvents(db, { taskId: "T-safe" });
expect(rows).toHaveLength(1);
expect(rows[0].meta).toEqual({ durationMs: 12 });
// No tool-argument/content fields are present.
const metaKeys = Object.keys(rows[0].meta ?? {});
expect(metaKeys).not.toContain("command");
expect(metaKeys).not.toContain("args");
expect(metaKeys).not.toContain("content");
});
it("skips a malformed event (unknown kind) without throwing", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const ok = emitUsageEvent(db, {
// @ts-expect-error intentionally invalid kind
kind: "not_a_real_kind",
taskId: "T-bad",
});
expect(ok).toBe(false);
expect(queryUsageEvents(db, { taskId: "T-bad" })).toHaveLength(0);
warn.mockRestore();
});
it("range-queries by inclusive ts bounds, ordered ascending", () => {
emitUsageEvent(db, { kind: "tool_call", taskId: "T-r", toolName: "Read", ts: "2026-01-01T00:00:00.000Z" });
emitUsageEvent(db, { kind: "tool_call", taskId: "T-r", toolName: "Edit", ts: "2026-01-02T00:00:00.000Z" });
emitUsageEvent(db, { kind: "tool_call", taskId: "T-r", toolName: "Bash", ts: "2026-01-03T00:00:00.000Z" });
const rows = queryUsageEvents(db, {
from: "2026-01-02T00:00:00.000Z",
to: "2026-01-03T00:00:00.000Z",
});
expect(rows.map((r) => r.toolName)).toEqual(["Edit", "Bash"]);
});
it("counts events grouped by a column over a range", () => {
emitUsageEvent(db, { kind: "tool_call", toolName: "Read", category: "read" });
emitUsageEvent(db, { kind: "tool_call", toolName: "Grep", category: "read" });
emitUsageEvent(db, { kind: "tool_call", toolName: "Bash", category: "execute" });
const byCategory = countUsageEventsBy(db, "category");
const map = new Map(byCategory.map((r) => [r.key, r.count]));
expect(map.get("read")).toBe(2);
expect(map.get("execute")).toBe(1);
});
it("records a chat-style event with null taskId and a set agentId", () => {
emitUsageEvent(db, { kind: "user_message", taskId: null, agentId: "A-chat" });
const rows = queryUsageEvents(db, { kind: "user_message" });
expect(rows).toHaveLength(1);
expect(rows[0].taskId).toBeNull();
expect(rows[0].agentId).toBe("A-chat");
});
// Migration: seed a DB at the PREVIOUS schema version, run migrate, assert
// the table exists and SCHEMA_VERSION equals the highest migration target.
// Fresh-DB tests cannot catch the early-return bug this guards.
it("creates usage_events when migrating from the previous schema version", () => {
db.exec("DROP INDEX IF EXISTS idxUsageEventsTs");
db.exec("DROP INDEX IF EXISTS idxUsageEventsTaskId");
db.exec("DROP INDEX IF EXISTS idxUsageEventsAgentId");
db.exec("DROP TABLE IF EXISTS usage_events");
db.prepare("UPDATE __meta SET value = ? WHERE key = 'schemaVersion'").run(String(SCHEMA_VERSION - 1));
(db as unknown as { migrate: () => void }).migrate();
const table = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='usage_events'")
.get() as { name: string } | undefined;
expect(table?.name).toBe("usage_events");
expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION);
// The migrated table is writable and queryable.
emitUsageEvent(db, { kind: "session_start", taskId: "T-mig", agentId: "A-mig" });
expect(queryUsageEvents(db, { taskId: "T-mig" })).toHaveLength(1);
});
it("SCHEMA_VERSION matches the highest applied migration on a fresh DB", () => {
expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION);
});
});

View File

@@ -162,7 +162,7 @@ export function isFts5CorruptionError(error: unknown): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 117;
const SCHEMA_VERSION = 118;
const TASKS_FTS_AUTOMERGE = 8;
const TASKS_FTS_CRISISMERGE = 16;
@@ -1207,6 +1207,29 @@ CREATE TABLE IF NOT EXISTS todo_items (
CREATE INDEX IF NOT EXISTS idxTodoListsProjectId ON todo_lists(projectId);
CREATE INDEX IF NOT EXISTS idxTodoItemsListId ON todo_items(listId);
CREATE INDEX IF NOT EXISTS idxTodoItemsSortOrder ON todo_items(listId, sortOrder);
-- Normalized, queryable telemetry of agent activity (tool calls, messages,
-- session lifecycle). Fed by emitUsageEvent from the executor/session layer so
-- analytics never has to parse per-task JSONL agent logs at query time.
-- The meta column carries only non-sensitive descriptors (error code,
-- category, duration) -- never tool arguments/content/credentials -- and is
-- capped at write (see usage-events.ts).
CREATE TABLE IF NOT EXISTS usage_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
kind TEXT NOT NULL,
taskId TEXT,
agentId TEXT,
nodeId TEXT,
model TEXT,
provider TEXT,
toolName TEXT,
category TEXT,
meta TEXT
);
CREATE INDEX IF NOT EXISTS idxUsageEventsTs ON usage_events(ts);
CREATE INDEX IF NOT EXISTS idxUsageEventsTaskId ON usage_events(taskId);
CREATE INDEX IF NOT EXISTS idxUsageEventsAgentId ON usage_events(agentId);
`;
const TABLE_LEVEL_CONSTRAINT_PREFIXES = new Set([
@@ -4718,6 +4741,38 @@ export class Database {
});
}
// Migration 118: Queryable usage_events telemetry table (tool calls,
// messages, session lifecycle). Mirrors the SCHEMA_SQL definition above so
// a fresh-from-SCHEMA_SQL DB and a migrated DB converge on the same table.
if (version < 118) {
this.applyMigration(118, () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS usage_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
kind TEXT NOT NULL,
taskId TEXT,
agentId TEXT,
nodeId TEXT,
model TEXT,
provider TEXT,
toolName TEXT,
category TEXT,
meta TEXT
)
`);
this.db.exec(`
CREATE INDEX IF NOT EXISTS idxUsageEventsTs ON usage_events(ts)
`);
this.db.exec(`
CREATE INDEX IF NOT EXISTS idxUsageEventsTaskId ON usage_events(taskId)
`);
this.db.exec(`
CREATE INDEX IF NOT EXISTS idxUsageEventsAgentId ON usage_events(agentId)
`);
});
}
}
/**

View File

@@ -517,6 +517,19 @@ export { computeRetrySummary, RETRY_STORM_WARNING_RATIO } from "./retry-summary.
export { RetryStormError, serializeRetryStormError } from "./retry-storm-error.js";
export { aggregateAgentTokenUsage } from "./agent-token-usage.js";
export type { AgentTokenUsageSummary, AgentTokenUsageWindowSummary } from "./agent-token-usage.js";
export {
emitUsageEvent,
queryUsageEvents,
countUsageEventsBy,
categorizeToolName,
USAGE_EVENT_META_MAX_BYTES,
} from "./usage-events.js";
export type {
UsageEvent,
UsageEventInput,
UsageEventKind,
UsageEventRangeQuery,
} from "./usage-events.js";
export {
STALLED_REVIEW_REENQUEUE_THRESHOLD,
STALLED_REVIEW_INVALID_TRANSITION_THRESHOLD,

View File

@@ -142,6 +142,7 @@ import {
readAgentLogEntriesByTimeRange,
} from "./agent-log-file-store.js";
import { truncateAgentLogDetail } from "./agent-log-constants.js";
import { emitUsageEvent as emitUsageEventToDb, type UsageEventInput } from "./usage-events.js";
import { validateNodeOverrideChange } from "./node-override-guard.js";
import { sanitizeTitle, summarizeTitle } from "./ai-summarize.js";
import { extractTaskIdTokens, normalizeTitleForTaskId } from "./task-title-id-drift.js";
@@ -11679,6 +11680,22 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
}
}
/**
* Append a normalized telemetry row to `usage_events` (tool calls, messages,
* session lifecycle) for the Command Center analytics layer. Callers in the
* executor/session layer pass `model`/`provider`/`nodeId`/`category` from the
* session context (see usage-events.ts / KTD3).
*
* **Fail-soft**: the underlying helper swallows malformed events and write
* errors, so this never throws and never aborts the agent-log write or the
* agent hot path.
*
* @returns `true` if a row was inserted, `false` if the event was skipped.
*/
emitUsageEvent(event: UsageEventInput): boolean {
return emitUsageEventToDb(this.db, event);
}
/**
* Flush all buffered agent log entries to per-task JSONL files.
* Called when the buffer is full or on a timer.

View File

@@ -0,0 +1,280 @@
import type { Database } from "./db.js";
/**
* Queryable telemetry of agent activity (tool calls, messages, session
* lifecycle), persisted to the `usage_events` table (db.ts schema). This is the
* normalized source the Command Center analytics layer reads from, so it does
* not have to parse per-task JSONL agent logs at query time.
*
* Events are appended via {@link emitUsageEvent} from the executor/session layer
* where `model`/`provider`/`nodeId`/`category` are already in scope (see
* KTD3/U1). The append helper is intentionally fail-soft: a malformed event or a
* write error is swallowed so it never aborts the underlying agent-log write or
* the agent hot path.
*/
/**
* The kind of activity an event records.
*
* - `tool_call` — an agent invoked a tool (agent-log `type: "tool"` maps here;
* `AgentLogType` has no `tool_call` member).
* - `tool_result` / `tool_error` — the tool completed / failed.
* - `user_message` — a human-authored message (chat/CLI sessions).
* - `session_start` / `session_stop` — session lifecycle.
*/
export type UsageEventKind =
| "tool_call"
| "tool_result"
| "tool_error"
| "user_message"
| "session_start"
| "session_stop";
const USAGE_EVENT_KINDS: ReadonlySet<string> = new Set<UsageEventKind>([
"tool_call",
"tool_result",
"tool_error",
"user_message",
"session_start",
"session_stop",
]);
/**
* Maximum serialized byte size of a `meta` payload. Events whose `meta`
* exceeds this cap are rejected at write (the whole event is skipped) rather
* than truncated, so an oversized payload can never silently land partial data.
*/
export const USAGE_EVENT_META_MAX_BYTES = 4096;
/** An event to append to `usage_events`. */
export interface UsageEventInput {
kind: UsageEventKind;
/** ISO-8601 timestamp. Defaults to now when omitted. */
ts?: string;
taskId?: string | null;
agentId?: string | null;
/** Workflow/session node this event belongs to; null when no node context. */
nodeId?: string | null;
model?: string | null;
provider?: string | null;
toolName?: string | null;
category?: string | null;
/**
* Non-sensitive descriptors only (error code, category, duration). NEVER tool
* arguments/content or credential-class fields. Capped at
* {@link USAGE_EVENT_META_MAX_BYTES}; over the cap, the event is rejected.
*/
meta?: Record<string, unknown> | null;
}
/** A row read back from `usage_events`. */
export interface UsageEvent {
id: number;
ts: string;
kind: UsageEventKind;
taskId: string | null;
agentId: string | null;
nodeId: string | null;
model: string | null;
provider: string | null;
toolName: string | null;
category: string | null;
meta: Record<string, unknown> | null;
}
interface UsageEventRow {
id: number;
ts: string;
kind: string;
taskId: string | null;
agentId: string | null;
nodeId: string | null;
model: string | null;
provider: string | null;
toolName: string | null;
category: string | null;
meta: string | null;
}
/**
* Coarse tool category derived from a tool name, for the Tools analytics area.
* Pure and side-effect free; callers may also pass an explicit `category`.
*/
export function categorizeToolName(toolName: string | null | undefined): string {
if (!toolName) return "other";
const name = toolName.toLowerCase();
if (name === "read" || name === "grep" || name === "glob" || name === "ls" || name.includes("search")) {
return "read";
}
if (name === "edit" || name === "write" || name === "multiedit" || name.includes("notebook")) {
return "edit";
}
if (name === "bash" || name.includes("exec") || name.includes("command") || name.includes("terminal")) {
return "execute";
}
if (name.includes("web") || name.includes("fetch") || name.includes("http")) {
return "network";
}
return "other";
}
/**
* Validate and serialize a `meta` payload. Returns the serialized JSON string,
* or throws if it exceeds the byte cap. `null`/`undefined` serialize to `null`.
*/
function serializeMeta(meta: Record<string, unknown> | null | undefined): string | null {
if (meta === undefined || meta === null) return null;
const serialized = JSON.stringify(meta);
if (serialized === undefined) return null;
if (Buffer.byteLength(serialized, "utf8") > USAGE_EVENT_META_MAX_BYTES) {
throw new Error(
`usage_events meta payload exceeds ${USAGE_EVENT_META_MAX_BYTES} bytes (got ${Buffer.byteLength(serialized, "utf8")})`,
);
}
return serialized;
}
/**
* Append a single usage event. **Fail-soft**: a malformed event (unknown kind),
* an oversized `meta`, or any DB error is logged and swallowed — it must never
* throw, so it cannot abort the underlying agent-log write or the hot path.
*
* @returns `true` if the row was inserted, `false` if the event was skipped.
*/
export function emitUsageEvent(db: Database, event: UsageEventInput): boolean {
try {
if (!event || !USAGE_EVENT_KINDS.has(event.kind)) {
return false;
}
const ts = event.ts ?? new Date().toISOString();
const meta = serializeMeta(event.meta);
db.prepare(
`INSERT INTO usage_events
(ts, kind, taskId, agentId, nodeId, model, provider, toolName, category, meta)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
ts,
event.kind,
event.taskId ?? null,
event.agentId ?? null,
event.nodeId ?? null,
event.model ?? null,
event.provider ?? null,
event.toolName ?? null,
event.category ?? null,
meta,
);
return true;
} catch (err) {
console.warn("[fusion] emitUsageEvent skipped a malformed/failed event:", err);
return false;
}
}
/** Filters for {@link queryUsageEvents}. All bounds are inclusive. */
export interface UsageEventRangeQuery {
/** ISO-8601 lower bound (inclusive). */
from?: string;
/** ISO-8601 upper bound (inclusive). */
to?: string;
kind?: UsageEventKind;
taskId?: string;
agentId?: string;
}
function rowToUsageEvent(row: UsageEventRow): UsageEvent {
let meta: Record<string, unknown> | null = null;
if (row.meta) {
try {
meta = JSON.parse(row.meta) as Record<string, unknown>;
} catch {
meta = null;
}
}
return {
id: row.id,
ts: row.ts,
kind: row.kind as UsageEventKind,
taskId: row.taskId,
agentId: row.agentId,
nodeId: row.nodeId,
model: row.model,
provider: row.provider,
toolName: row.toolName,
category: row.category,
meta,
};
}
/**
* Range-scan `usage_events` ordered by timestamp ascending. Mirrors the
* windowed-scan shape of `agent-token-usage.ts`, generalized to an arbitrary
* `(from, to)` range with optional kind/task/agent filters.
*/
export function queryUsageEvents(db: Database, query: UsageEventRangeQuery = {}): UsageEvent[] {
const clauses: string[] = [];
const params: Array<string> = [];
if (query.from !== undefined) {
clauses.push("ts >= ?");
params.push(query.from);
}
if (query.to !== undefined) {
clauses.push("ts <= ?");
params.push(query.to);
}
if (query.kind !== undefined) {
clauses.push("kind = ?");
params.push(query.kind);
}
if (query.taskId !== undefined) {
clauses.push("taskId = ?");
params.push(query.taskId);
}
if (query.agentId !== undefined) {
clauses.push("agentId = ?");
params.push(query.agentId);
}
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
const rows = db
.prepare(`SELECT * FROM usage_events ${where} ORDER BY ts ASC, id ASC`)
.all(...params) as UsageEventRow[];
return rows.map(rowToUsageEvent);
}
/**
* Count `usage_events` grouped by a single column over a range. Convenience for
* the analytics aggregators (e.g. tool calls by `category`).
*/
export function countUsageEventsBy(
db: Database,
column: "kind" | "category" | "toolName" | "model" | "provider" | "nodeId" | "agentId",
query: UsageEventRangeQuery = {},
): Array<{ key: string | null; count: number }> {
const clauses: string[] = [];
const params: Array<string> = [];
if (query.from !== undefined) {
clauses.push("ts >= ?");
params.push(query.from);
}
if (query.to !== undefined) {
clauses.push("ts <= ?");
params.push(query.to);
}
if (query.kind !== undefined) {
clauses.push("kind = ?");
params.push(query.kind);
}
if (query.taskId !== undefined) {
clauses.push("taskId = ?");
params.push(query.taskId);
}
if (query.agentId !== undefined) {
clauses.push("agentId = ?");
params.push(query.agentId);
}
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
const rows = db
.prepare(`SELECT ${column} AS key, COUNT(*) AS count FROM usage_events ${where} GROUP BY ${column}`)
.all(...params) as Array<{ key: string | null; count: number }>;
return rows;
}

View File

@@ -496,4 +496,96 @@ describe("AgentLogger", () => {
);
});
});
// ── usage_events emission (U1) ─────────────────────────────────────
describe("usage_events emission", () => {
function createUsageStore() {
return {
appendAgentLog: vi.fn().mockResolvedValue(undefined),
emitUsageEvent: vi.fn().mockReturnValue(true),
} as unknown as TaskStore & { emitUsageEvent: ReturnType<typeof vi.fn> };
}
it("emits a tool_call usage event with model/provider/nodeId on tool start", () => {
const store = createUsageStore();
const logger = new AgentLogger({ store, taskId: "FN-UE-1", agent: "executor" });
logger.setUsageContext({
model: "claude-sonnet-4-5",
provider: "anthropic",
nodeId: "node-x",
agentId: "A-1",
});
logger.onToolStart("Read", { path: "secret/credentials.env" });
expect(store.emitUsageEvent).toHaveBeenCalledTimes(1);
const event = store.emitUsageEvent.mock.calls[0][0];
expect(event).toMatchObject({
kind: "tool_call",
taskId: "FN-UE-1",
agentId: "A-1",
nodeId: "node-x",
model: "claude-sonnet-4-5",
provider: "anthropic",
toolName: "Read",
category: "read",
});
// The tool-argument content (the file path) MUST NOT appear in meta.
const meta = (event.meta ?? {}) as Record<string, unknown>;
expect(JSON.stringify(meta)).not.toContain("credentials.env");
});
it("does not emit usage events when no usage context is set", () => {
const store = createUsageStore();
const logger = new AgentLogger({ store, taskId: "FN-UE-2" });
logger.onToolStart("Bash", { command: "ls" });
expect(store.emitUsageEvent).not.toHaveBeenCalled();
});
it("integration: a session calling 3 tools yields 3 tool_call rows with model/provider/nodeId", () => {
const store = createUsageStore();
const logger = new AgentLogger({ store, taskId: "FN-UE-3", agent: "executor" });
logger.setUsageContext({
model: "gpt-5",
provider: "openai",
nodeId: "local",
agentId: "A-3",
});
logger.onToolStart("Read", { path: "a.ts" });
logger.onToolStart("Edit", { path: "a.ts" });
logger.onToolStart("Bash", { command: "pnpm test" });
const toolCalls = store.emitUsageEvent.mock.calls
.map((c) => c[0])
.filter((e) => e.kind === "tool_call");
expect(toolCalls).toHaveLength(3);
expect(toolCalls.map((e) => e.toolName)).toEqual(["Read", "Edit", "Bash"]);
for (const event of toolCalls) {
expect(event.model).toBe("gpt-5");
expect(event.provider).toBe("openai");
expect(event.nodeId).toBe("local");
expect(event.agentId).toBe("A-3");
}
});
it("emits tool_result with a duration descriptor and no result payload", () => {
const store = createUsageStore();
const logger = new AgentLogger({ store, taskId: "FN-UE-4" });
logger.setUsageContext({ model: "m", provider: "p", nodeId: "n", agentId: "a" });
logger.onToolStart("Bash", { command: "echo hi" });
logger.onToolEnd("Bash", false, "super-secret-output");
const endEvent = store.emitUsageEvent.mock.calls
.map((c) => c[0])
.find((e) => e.kind === "tool_result");
expect(endEvent).toBeDefined();
expect(endEvent.toolName).toBe("Bash");
const meta = (endEvent.meta ?? {}) as Record<string, unknown>;
expect(meta).toHaveProperty("durationMs");
// The tool result payload MUST NOT leak into meta.
expect(JSON.stringify(meta)).not.toContain("super-secret-output");
});
});
});

View File

@@ -1,6 +1,24 @@
import type { TaskStore, AgentLogEntry, AgentRole } from "@fusion/core";
import { categorizeToolName } from "@fusion/core";
import { createLogger } from "./logger.js";
/**
* Session-context fields that let the logger emit normalized `usage_events`
* telemetry (KTD3/U1) alongside its agent-log writes. Populated by the
* executor/session layer where `model`/`provider`/`nodeId` are resolved; when
* absent, no usage events are emitted (the agent-log behavior is unchanged).
*/
export interface AgentLoggerUsageContext {
/** Resolved model id for the running session, when known. */
model?: string | null;
/** Resolved provider for the running session, when known. */
provider?: string | null;
/** Workflow/session node the session is routed to, when known. */
nodeId?: string | null;
/** The agent id producing the activity, when known. */
agentId?: string | null;
}
/** Default byte threshold before an automatic flush. */
const FLUSH_SIZE_BYTES = 1024;
/** Default timer interval (ms) for periodic flush of small writes. */
@@ -68,6 +86,12 @@ export interface AgentLoggerOptions {
flushSizeBytes?: number;
/** Timer interval (ms) for periodic flush. Defaults to 500. */
flushIntervalMs?: number;
/**
* When provided (with `store` + `taskId`), tool start/end callbacks also emit
* normalized `usage_events` telemetry carrying the session's model/provider/
* node context. Omit to leave agent-log behavior unchanged.
*/
usageContext?: AgentLoggerUsageContext;
}
/**
@@ -113,6 +137,9 @@ export class AgentLogger {
private readonly log = createLogger("agent-logger");
private readonly persistAgentToolOutput: boolean;
private readonly persistAgentThinkingLog: boolean;
private usageContext?: AgentLoggerUsageContext;
/** Tracks tool start times so tool_result/tool_error can record a duration. */
private readonly toolStartedAt = new Map<string, number>();
constructor(options: AgentLoggerOptions) {
this.store = options.store;
@@ -125,6 +152,7 @@ export class AgentLogger {
this.flushIntervalMs = options.flushIntervalMs ?? FLUSH_INTERVAL_MS;
this.persistAgentToolOutput = options.persistAgentToolOutput !== false;
this.persistAgentThinkingLog = options.persistAgentThinkingLog === true;
this.usageContext = options.usageContext;
// Bind callbacks so they can be passed directly as function references
this.onText = this.onText.bind(this);
@@ -133,6 +161,39 @@ export class AgentLogger {
this.onToolEnd = this.onToolEnd.bind(this);
}
/**
* Set (or update) the session context used to emit `usage_events` telemetry.
* The executor resolves `model`/`provider`/`nodeId` after the logger is
* constructed, so it calls this once those are known.
*/
setUsageContext(context: AgentLoggerUsageContext | undefined): void {
this.usageContext = context;
}
/**
* Emit a normalized tool `usage_events` row through the task store, if a store,
* taskId, and usage context are all available. Fail-soft via store.emitUsageEvent.
*/
private emitToolUsageEvent(
kind: "tool_call" | "tool_result" | "tool_error",
toolName: string,
meta?: Record<string, unknown>,
): void {
const ctx = this.usageContext;
if (!ctx || !this.store || !this.taskId) return;
this.store.emitUsageEvent({
kind,
taskId: this.taskId,
agentId: ctx.agentId ?? null,
nodeId: ctx.nodeId ?? null,
model: ctx.model ?? null,
provider: ctx.provider ?? null,
toolName,
category: categorizeToolName(toolName),
...(meta !== undefined && { meta }),
});
}
/**
* Callback for agent text deltas. Buffers text and flushes on size
* threshold or after a timer interval. Compatible with `AgentOptions.onText`.
@@ -178,6 +239,10 @@ export class AgentLogger {
this.flushThinkingBuffer();
const detail = summarizeToolArgs(name, args);
this.writeEntry(name, "tool", detail, `Failed to log tool start "${name}" for ${this.taskId}`);
// agent-log type "tool" maps to usage_events kind "tool_call". meta carries
// only non-sensitive descriptors (category) — never the tool arguments.
this.toolStartedAt.set(name, Date.now());
this.emitToolUsageEvent("tool_call", name);
}
/**
@@ -196,6 +261,18 @@ export class AgentLogger {
detail = typeof result === "string" ? result : JSON.stringify(result);
}
this.writeEntry(name, type, detail, `Failed to log tool end "${name}" (${type}) for ${this.taskId}`);
// Record completion as tool_result/tool_error with a duration descriptor.
// meta NEVER includes the tool result payload — only non-sensitive metrics.
const startedAt = this.toolStartedAt.get(name);
if (startedAt !== undefined) this.toolStartedAt.delete(name);
const meta: Record<string, unknown> = {};
if (startedAt !== undefined) meta.durationMs = Date.now() - startedAt;
if (isError) meta.isError = true;
this.emitToolUsageEvent(
isError ? "tool_error" : "tool_result",
name,
Object.keys(meta).length > 0 ? meta : undefined,
);
}
/**

View File

@@ -7689,6 +7689,17 @@ export class TaskExecutor {
const executorFallbackModelId = settings.fallbackModelId;
const executorThinkingLevel = detail.thinkingLevel ?? settings.defaultThinkingLevel;
// U1 telemetry: now that the session model/provider/node are resolved,
// give the agent logger the context it needs to emit usage_events tool
// rows (KTD3). nodeId is sourced from the routed/effective node, null
// when the task has no node context.
agentLogger.setUsageContext({
model: executorModelId ?? null,
provider: executorProvider ?? null,
nodeId: detail.effectiveNodeId ?? detail.nodeId ?? null,
agentId: engineRunContext.agentId ?? null,
});
// Determine whether we're resuming a previous session (pause/resume)
// or starting fresh. Use file-based sessions so conversation state
// persists across pause/unpause cycles. Resume is allowed only when

View File

@@ -743,10 +743,10 @@ describe("RoadmapStore", () => {
});
describe("schema version", () => {
it("schema version is 117 after init", () => {
it("schema version is 118 after init", () => {
// Tracks @fusion/core's SCHEMA_VERSION (the roadmap store layers on core's
// Database). Bump this in lockstep when core adds a migration.
expect(db.getSchemaVersion()).toBe(117);
expect(db.getSchemaVersion()).toBe(118);
});
});