Spaces:
Paused
Paused
| /** | |
| * Usage Stats β time-series snapshot recording and aggregation. | |
| * | |
| * Records periodic snapshots of cumulative token usage across all accounts. | |
| * Snapshots are persisted to data/usage-history.json and pruned to 7 days. | |
| * Aggregation (delta computation, bucketing) happens on read. | |
| */ | |
| import { | |
| readFileSync, | |
| writeFileSync, | |
| renameSync, | |
| existsSync, | |
| mkdirSync, | |
| } from "fs"; | |
| import { resolve, dirname } from "path"; | |
| import { getDataDir } from "../paths.js"; | |
| import type { AccountPool } from "./account-pool.js"; | |
| // ββ Types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export interface UsageSnapshot { | |
| timestamp: string; // ISO 8601 | |
| totals: { | |
| input_tokens: number; | |
| output_tokens: number; | |
| request_count: number; | |
| active_accounts: number; | |
| }; | |
| } | |
| interface UsageHistoryFile { | |
| version: 1; | |
| snapshots: UsageSnapshot[]; | |
| } | |
| export interface UsageDataPoint { | |
| timestamp: string; | |
| input_tokens: number; | |
| output_tokens: number; | |
| request_count: number; | |
| } | |
| export interface UsageSummary { | |
| total_input_tokens: number; | |
| total_output_tokens: number; | |
| total_request_count: number; | |
| total_accounts: number; | |
| active_accounts: number; | |
| } | |
| // ββ Constants ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days | |
| const HISTORY_FILE = "usage-history.json"; | |
| // ββ Persistence interface (injectable for testing) βββββββββββββββββ | |
| export interface UsageStatsPersistence { | |
| load(): UsageHistoryFile; | |
| save(data: UsageHistoryFile): void; | |
| } | |
| export function createFsUsageStatsPersistence(): UsageStatsPersistence { | |
| function getFilePath(): string { | |
| return resolve(getDataDir(), HISTORY_FILE); | |
| } | |
| return { | |
| load(): UsageHistoryFile { | |
| try { | |
| const filePath = getFilePath(); | |
| if (!existsSync(filePath)) return { version: 1, snapshots: [] }; | |
| const raw = readFileSync(filePath, "utf-8"); | |
| const data = JSON.parse(raw) as UsageHistoryFile; | |
| if (!Array.isArray(data.snapshots)) return { version: 1, snapshots: [] }; | |
| return data; | |
| } catch { | |
| return { version: 1, snapshots: [] }; | |
| } | |
| }, | |
| save(data: UsageHistoryFile): void { | |
| try { | |
| const filePath = getFilePath(); | |
| const dir = dirname(filePath); | |
| if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); | |
| const tmpFile = filePath + ".tmp"; | |
| writeFileSync(tmpFile, JSON.stringify(data), "utf-8"); | |
| renameSync(tmpFile, filePath); | |
| } catch (err) { | |
| console.error("[UsageStats] Failed to persist:", err instanceof Error ? err.message : err); | |
| } | |
| }, | |
| }; | |
| } | |
| // ββ Store ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export class UsageStatsStore { | |
| private persistence: UsageStatsPersistence; | |
| private snapshots: UsageSnapshot[]; | |
| constructor(persistence?: UsageStatsPersistence) { | |
| this.persistence = persistence ?? createFsUsageStatsPersistence(); | |
| this.snapshots = this.persistence.load().snapshots; | |
| } | |
| /** Take a snapshot of current cumulative usage across all accounts. */ | |
| recordSnapshot(pool: AccountPool): void { | |
| const entries = pool.getAllEntries(); | |
| const now = new Date().toISOString(); | |
| let input_tokens = 0; | |
| let output_tokens = 0; | |
| let request_count = 0; | |
| let active_accounts = 0; | |
| for (const entry of entries) { | |
| input_tokens += entry.usage.input_tokens; | |
| output_tokens += entry.usage.output_tokens; | |
| request_count += entry.usage.request_count; | |
| if (entry.status === "active") active_accounts++; | |
| } | |
| this.snapshots.push({ | |
| timestamp: now, | |
| totals: { input_tokens, output_tokens, request_count, active_accounts }, | |
| }); | |
| // Prune old snapshots | |
| const cutoff = Date.now() - MAX_AGE_MS; | |
| this.snapshots = this.snapshots.filter( | |
| (s) => new Date(s.timestamp).getTime() >= cutoff, | |
| ); | |
| this.persistence.save({ version: 1, snapshots: this.snapshots }); | |
| } | |
| /** Get current cumulative summary from live pool data. */ | |
| getSummary(pool: AccountPool): UsageSummary { | |
| const entries = pool.getAllEntries(); | |
| let total_input_tokens = 0; | |
| let total_output_tokens = 0; | |
| let total_request_count = 0; | |
| let active_accounts = 0; | |
| for (const entry of entries) { | |
| total_input_tokens += entry.usage.input_tokens; | |
| total_output_tokens += entry.usage.output_tokens; | |
| total_request_count += entry.usage.request_count; | |
| if (entry.status === "active") active_accounts++; | |
| } | |
| return { | |
| total_input_tokens, | |
| total_output_tokens, | |
| total_request_count, | |
| total_accounts: entries.length, | |
| active_accounts, | |
| }; | |
| } | |
| /** | |
| * Get usage history as delta data points, aggregated by granularity. | |
| * @param hours - how many hours of history to return | |
| * @param granularity - "raw" | "hourly" | "daily" | |
| */ | |
| getHistory( | |
| hours: number, | |
| granularity: "raw" | "hourly" | "daily", | |
| ): UsageDataPoint[] { | |
| const cutoff = Date.now() - hours * 60 * 60 * 1000; | |
| const filtered = this.snapshots.filter( | |
| (s) => new Date(s.timestamp).getTime() >= cutoff, | |
| ); | |
| if (filtered.length < 2) return []; | |
| // Compute deltas between consecutive snapshots | |
| const deltas: UsageDataPoint[] = []; | |
| for (let i = 1; i < filtered.length; i++) { | |
| const prev = filtered[i - 1].totals; | |
| const curr = filtered[i].totals; | |
| deltas.push({ | |
| timestamp: filtered[i].timestamp, | |
| input_tokens: Math.max(0, curr.input_tokens - prev.input_tokens), | |
| output_tokens: Math.max(0, curr.output_tokens - prev.output_tokens), | |
| request_count: Math.max(0, curr.request_count - prev.request_count), | |
| }); | |
| } | |
| if (granularity === "raw") return deltas; | |
| // Bucket into time intervals | |
| const bucketMs = granularity === "hourly" ? 3600_000 : 86400_000; | |
| return bucketize(deltas, bucketMs); | |
| } | |
| /** Get raw snapshot count (for testing). */ | |
| get snapshotCount(): number { | |
| return this.snapshots.length; | |
| } | |
| } | |
| function bucketize(deltas: UsageDataPoint[], bucketMs: number): UsageDataPoint[] { | |
| if (deltas.length === 0) return []; | |
| const buckets = new Map<number, UsageDataPoint>(); | |
| for (const d of deltas) { | |
| const t = new Date(d.timestamp).getTime(); | |
| const bucketKey = Math.floor(t / bucketMs) * bucketMs; | |
| const existing = buckets.get(bucketKey); | |
| if (existing) { | |
| existing.input_tokens += d.input_tokens; | |
| existing.output_tokens += d.output_tokens; | |
| existing.request_count += d.request_count; | |
| } else { | |
| buckets.set(bucketKey, { | |
| timestamp: new Date(bucketKey).toISOString(), | |
| input_tokens: d.input_tokens, | |
| output_tokens: d.output_tokens, | |
| request_count: d.request_count, | |
| }); | |
| } | |
| } | |
| return [...buckets.values()].sort( | |
| (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), | |
| ); | |
| } | |