diff --git a/.changeset/usage-events-retention.md b/.changeset/usage-events-retention.md new file mode 100644 index 0000000000..0e367c692d --- /dev/null +++ b/.changeset/usage-events-retention.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Stop the usage telemetry log from growing without bound and bloating the Fusion database. +category: fix +dev: usage_events was absent from operational-log retention, so it grew unbounded (observed ~187k rows / ~28MB with nothing ever aged out). pruneOperationalLogs now prunes usage_events on the same operationalLogRetentionDays cadence, keyed off its `ts` column (not `timestamp`). Existing rows still require a one-time VACUUM to reclaim on-disk space. diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index f667939d84..1ff19026d6 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -3605,6 +3605,11 @@ describe("Database operational-log retention and recovery-table cleanup", () => ).run(id, agentId, createdAt); } + function insertUsageEvent(ts: string): void { + // usage_events.id is INTEGER PRIMARY KEY AUTOINCREMENT — let it auto-assign. + db.prepare("INSERT INTO usage_events (ts, kind) VALUES (?, 'tool_call')").run(ts); + } + it("pruneOperationalLogs deletes rows older than the retention window", () => { const old = new Date(Date.now() - 200 * 86_400_000).toISOString(); const recent = new Date(Date.now() - 1 * 86_400_000).toISOString(); @@ -3675,8 +3680,27 @@ describe("Database operational-log retention and recovery-table cleanup", () => expect(remaining.map((row) => row.id)).toEqual(["agent-1-recent", "agent-2-old-2"]); }); + it("pruneOperationalLogs deletes old usage_events by their `ts` column but keeps recent ones", () => { + // FNXC:TelemetryRetention 2026-07-08-00:00: + // Regression guard for the unbounded-growth fix: usage_events must age out on + // the same retention cadence as the other operational logs, keyed off its `ts` + // column (not `timestamp`), which is why it needs its own delete path. + const old = new Date(Date.now() - 200 * 86_400_000).toISOString(); + const recent = new Date(Date.now() - 1 * 86_400_000).toISOString(); + insertUsageEvent(old); + insertUsageEvent(old); + insertUsageEvent(recent); + + const result = db.pruneOperationalLogs(90 * 86_400_000); + expect(result.deletedByTable.usage_events).toBe(2); + + const remaining = db.prepare("SELECT ts FROM usage_events").all() as Array<{ ts: string }>; + expect(remaining).toEqual([{ ts: recent }]); + }); + it("pruneOperationalLogs is a no-op when retention is disabled (<= 0)", () => { insertActivity("old-1", new Date(Date.now() - 200 * 86_400_000).toISOString()); + insertUsageEvent(new Date(Date.now() - 200 * 86_400_000).toISOString()); insertAgent("agent-1"); insertAgentRun({ id: "run-old-completed", @@ -3694,6 +3718,7 @@ describe("Database operational-log retention and recovery-table cleanup", () => const result = db.pruneOperationalLogs(0); expect(result.deletedTotal).toBe(0); expect(db.prepare("SELECT count(*) AS c FROM activityLog").get()).toMatchObject({ c: 1 }); + expect(db.prepare("SELECT count(*) AS c FROM usage_events").get()).toMatchObject({ c: 1 }); expect(db.prepare("SELECT count(*) AS c FROM agentRuns").get()).toMatchObject({ c: 1 }); expect(db.prepare("SELECT count(*) AS c FROM agentConfigRevisions").get()).toMatchObject({ c: 1 }); }); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index f0fc0198d3..1d9b3279b1 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -2581,6 +2581,22 @@ export class Database { } } + /* + * FNXC:TelemetryRetention 2026-07-08-00:00: + * usage_events is an append-only per-tool telemetry log. Unlike the OPERATIONAL_LOG_TABLES set it originally had NO retention, so it grew unbounded and became the dominant driver of .fusion DB bloat once runAuditEvents was already 30-day capped (observed ~187k rows / ~28MB with no aged-out rows because nothing ever deleted them). + * Prune it on the same operationalLogRetentionDays cadence as the other operational logs. Its timestamp column is `ts` (not `timestamp`), so it cannot join the generic OPERATIONAL_LOG_TABLES loop above and gets its own delete here alongside the other column-name exceptions (agentRuns.endedAt, agentConfigRevisions.createdAt). + */ + if (this.tableExists("usage_events")) { + try { + recordChanges( + "usage_events", + this.db.prepare("DELETE FROM usage_events WHERE ts < ?").run(cutoffIso), + ); + } catch (error) { + console.warn("[fusion:db] Failed to prune operational log table usage_events", error); + } + } + return { deletedByTable, deletedTotal }; }