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:
@@ -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 }>;
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -91,6 +91,6 @@ describe("goals schema", () => {
|
||||
});
|
||||
|
||||
it("reports schema version 101", () => {
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
expect(db.getSchemaVersion()).toBe(118);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
204
packages/core/src/__tests__/usage-events.test.ts
Normal file
204
packages/core/src/__tests__/usage-events.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
280
packages/core/src/usage-events.ts
Normal file
280
packages/core/src/usage-events.ts
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user