UNPKG

ccusage-byobu

Version:

Real-time Claude Code API usage monitoring for byobu/tmux status bars with colored indicators, persistent caching, and automated integration

769 lines (680 loc) 20.6 kB
/** * Simple in-memory cache with TTL support for ccusage-byobu * Designed to cache ccusage command output to reduce frequent execution * * NOTE: Cache is DISABLED by default for byobu compatibility. * Set CCUSAGE_ENABLE_CACHE=1 to enable caching for interactive CLI usage. * * Configuration options: * - cleanupIntervalMs: Interval in milliseconds for automatic cleanup (default: 30000) * - maxKeys: Maximum number of keys before LRU eviction (default: null = unlimited) * - windowSize: Number of operations to track in rolling window (default: 100) * - intervalDuration: Duration in milliseconds for time-based statistics intervals (default: 60000) * * Environment variables: * - CCUSAGE_ENABLE_CACHE: Set to '1' or 'true' to enable caching (default: disabled) * - CCUSAGE_BYOBU_CACHE_CLEANUP_INTERVAL: Override cleanup interval in milliseconds * - CCUSAGE_BYOBU_CACHE_MAX_KEYS: Override max keys limit * - CCUSAGE_BYOBU_CACHE_WINDOW_SIZE: Override rolling window size * * Events emitted: * - 'set': { key, valueSize, ttl, timestamp } - When a value is stored * - 'get': { key, hit, timestamp } - When a value is requested * - 'eviction': { key, reason, timestamp } - When a value is evicted ('ttl', 'lru', 'manual') * - 'clear': { keysCleared, timestamp } - When cache is cleared * - 'cleanup': { evictedCount, timestamp } - When automatic cleanup runs */ import { EventEmitter } from 'node:events'; class Cache extends EventEmitter { constructor(options = {}) { super(); // Default configuration const defaultConfig = { cleanupIntervalMs: 30000, // 30 seconds maxKeys: null, // No limit by default windowSize: 100, // Rolling window size intervalDuration: 60000, // 1 minute intervals for time-based stats }; // Environment variable overrides with validation const envConfig = {}; if (process.env.CCUSAGE_BYOBU_CACHE_CLEANUP_INTERVAL) { const parsed = parseInt(process.env.CCUSAGE_BYOBU_CACHE_CLEANUP_INTERVAL, 10); if (!isNaN(parsed) && parsed > 0) { envConfig.cleanupIntervalMs = parsed; } } if (process.env.CCUSAGE_BYOBU_CACHE_MAX_KEYS) { const parsed = parseInt(process.env.CCUSAGE_BYOBU_CACHE_MAX_KEYS, 10); if (!isNaN(parsed) && parsed > 0) { envConfig.maxKeys = parsed; } } if (process.env.CCUSAGE_BYOBU_CACHE_WINDOW_SIZE) { const parsed = parseInt(process.env.CCUSAGE_BYOBU_CACHE_WINDOW_SIZE, 10); if (!isNaN(parsed) && parsed > 0) { envConfig.windowSize = parsed; } } // Merge configurations: defaults < environment < options this.config = { ...defaultConfig, ...envConfig, ...options, }; // Validate configuration if (!Number.isInteger(this.config.cleanupIntervalMs) || this.config.cleanupIntervalMs <= 0) { throw new Error( `cleanupIntervalMs must be a positive integer, got: ${this.config.cleanupIntervalMs}` ); } if ( this.config.maxKeys !== null && (!Number.isInteger(this.config.maxKeys) || this.config.maxKeys <= 0) ) { throw new Error(`maxKeys must be a positive integer or null, got: ${this.config.maxKeys}`); } if (!Number.isInteger(this.config.windowSize) || this.config.windowSize <= 0) { throw new Error(`windowSize must be a positive integer, got: ${this.config.windowSize}`); } this.store = new Map(); this.accessOrder = new Map(); // Track access order for LRU eviction this.stats = { hits: 0, misses: 0, evictions: 0, lruEvictions: 0, // Track LRU evictions separately }; // Rolling window statistics this.rollingWindow = { windowSize: this.config.windowSize, operations: [], // Array of { type: 'hit'|'miss', timestamp: Date.now() } windowStats: { hits: 0, misses: 0, hitRate: 0, }, }; // Time-based statistics this.timeBasedStats = { intervals: [], // Array of { startTime, endTime, hits, misses } currentInterval: { startTime: Date.now(), hits: 0, misses: 0, }, intervalDuration: this.config.intervalDuration, }; // Cleanup timer to prevent memory leaks (only if caching is enabled) const cacheEnabled = process.env.CCUSAGE_ENABLE_CACHE === '1' || process.env.CCUSAGE_ENABLE_CACHE === 'true'; if (cacheEnabled) { this.cleanupInterval = setInterval(() => { this.cleanup(); this.rotateTimeBasedStats(); }, this.config.cleanupIntervalMs); } } /** * Set a value in the cache with TTL * @param {string} key - Cache key * @param {any} value - Value to cache * @param {number} ttlMs - Time to live in milliseconds (default: 15000) */ set(key, value, ttlMs = 15000) { const now = Date.now(); const expiresAt = now + ttlMs; // If key already exists, update access order if (this.store.has(key)) { this.accessOrder.delete(key); } else if (this.config.maxKeys !== null && this.store.size >= this.config.maxKeys) { // Need to evict oldest entry (LRU) const oldestKey = this.accessOrder.keys().next().value; if (oldestKey !== undefined) { this.store.delete(oldestKey); this.accessOrder.delete(oldestKey); this.stats.lruEvictions++; this.stats.evictions++; // Emit eviction event this.emit('eviction', { key: oldestKey, reason: 'lru', timestamp: now, }); } else { // Handle case where Maps are out of sync console.warn('Cache Maps out of sync: no oldest key found despite size check'); } } this.store.set(key, { value, expiresAt, createdAt: now, }); // Update access order (most recently used goes to end) this.accessOrder.set(key, now); // Emit set event const valueSize = this._getValueSize(value); this.emit('set', { key, valueSize, ttl: ttlMs, timestamp: now, }); } /** * Get a value from the cache * @param {string} key - Cache key * @returns {any} Cached value or null if not found/expired */ get(key) { const now = Date.now(); const entry = this.store.get(key); if (!entry) { this.stats.misses++; this.recordOperation('miss'); // Emit get event (miss) this.emit('get', { key, hit: false, timestamp: now, }); return null; } if (now > entry.expiresAt) { // Expired entry this.store.delete(key); this.accessOrder.delete(key); this.stats.misses++; this.stats.evictions++; this.recordOperation('miss'); // Emit eviction event for expired entry this.emit('eviction', { key, reason: 'ttl', timestamp: now, }); // Emit get event (miss) this.emit('get', { key, hit: false, timestamp: now, }); return null; } // Update access order (move to end as most recently used) this.accessOrder.delete(key); this.accessOrder.set(key, now); this.stats.hits++; this.recordOperation('hit'); // Emit get event (hit) this.emit('get', { key, hit: true, timestamp: now, }); return entry.value; } /** * Check if a key exists and is not expired * @param {string} key - Cache key * @returns {boolean} True if key exists and is valid */ has(key) { const entry = this.store.get(key); if (!entry) { return false; } const now = Date.now(); if (now > entry.expiresAt) { this.store.delete(key); this.accessOrder.delete(key); this.stats.evictions++; // Emit eviction event for expired entry this.emit('eviction', { key, reason: 'ttl', timestamp: now, }); return false; } return true; } /** * Remove expired entries from cache */ cleanup() { const now = Date.now(); let evicted = 0; const evictedKeys = []; for (const [key, entry] of this.store.entries()) { if (now > entry.expiresAt) { this.store.delete(key); this.accessOrder.delete(key); evicted++; evictedKeys.push(key); // Emit individual eviction events this.emit('eviction', { key, reason: 'ttl', timestamp: now, }); } } this.stats.evictions += evicted; // Emit cleanup event if any keys were evicted if (evicted > 0) { this.emit('cleanup', { evictedCount: evicted, evictedKeys, timestamp: now, }); } } /** * Clear all cache entries */ clear() { const size = this.store.size; const now = Date.now(); // Get all keys before clearing const clearedKeys = Array.from(this.store.keys()); this.store.clear(); this.accessOrder.clear(); this.stats.evictions += size; // Emit clear event if (size > 0) { this.emit('clear', { keysCleared: size, clearedKeys, timestamp: now, }); } } /** * Record an operation for rolling window statistics * @param {'hit'|'miss'} type - Operation type */ recordOperation(type) { const now = Date.now(); // Add to rolling window this.rollingWindow.operations.push({ type, timestamp: now }); // Update window stats if (type === 'hit') { this.rollingWindow.windowStats.hits++; this.timeBasedStats.currentInterval.hits++; } else { this.rollingWindow.windowStats.misses++; this.timeBasedStats.currentInterval.misses++; } // Trim window if needed if (this.rollingWindow.operations.length > this.rollingWindow.windowSize) { const removed = this.rollingWindow.operations.shift(); if (removed.type === 'hit') { this.rollingWindow.windowStats.hits--; } else { this.rollingWindow.windowStats.misses--; } } // Update rolling window hit rate const windowTotal = this.rollingWindow.windowStats.hits + this.rollingWindow.windowStats.misses; this.rollingWindow.windowStats.hitRate = windowTotal > 0 ? ((this.rollingWindow.windowStats.hits / windowTotal) * 100).toFixed(2) : 0; } /** * Rotate time-based statistics */ rotateTimeBasedStats() { const now = Date.now(); // Check if current interval should be rotated if ( now - this.timeBasedStats.currentInterval.startTime >= this.timeBasedStats.intervalDuration ) { // Save current interval this.timeBasedStats.intervals.push({ ...this.timeBasedStats.currentInterval, endTime: now, }); // Keep only last 60 intervals (1 hour worth) if (this.timeBasedStats.intervals.length > 60) { this.timeBasedStats.intervals.shift(); } // Start new interval this.timeBasedStats.currentInterval = { startTime: now, hits: 0, misses: 0, }; } } /** * Get rolling window statistics * @returns {Object} Rolling window stats */ getRollingWindowStats() { return { windowSize: this.rollingWindow.windowSize, currentOperations: this.rollingWindow.operations.length, ...this.rollingWindow.windowStats, recentOperations: this.rollingWindow.operations.slice(-10), // Last 10 operations }; } /** * Get time-based statistics * @returns {Object} Time-based stats */ getTimeBasedStats() { const intervals = [...this.timeBasedStats.intervals]; // Include current interval if it has data if ( this.timeBasedStats.currentInterval.hits > 0 || this.timeBasedStats.currentInterval.misses > 0 ) { intervals.push({ ...this.timeBasedStats.currentInterval, endTime: Date.now(), isCurrentInterval: true, }); } // Calculate aggregate stats const totalHits = intervals.reduce((sum, interval) => sum + interval.hits, 0); const totalMisses = intervals.reduce((sum, interval) => sum + interval.misses, 0); const hitRate = totalHits + totalMisses > 0 ? ((totalHits / (totalHits + totalMisses)) * 100).toFixed(2) : 0; return { intervalDuration: this.timeBasedStats.intervalDuration, intervals: intervals.slice(-5), // Last 5 intervals aggregate: { totalHits, totalMisses, hitRate: `${hitRate}%`, periodCovered: intervals.length > 0 ? Date.now() - intervals[0].startTime : 0, }, }; } /** * Get cache statistics * @returns {Object} Cache statistics */ getStats() { const hitRate = this.stats.hits + this.stats.misses > 0 ? ((this.stats.hits / (this.stats.hits + this.stats.misses)) * 100).toFixed(2) : 0; return { ...this.stats, hitRate: `${hitRate}%`, size: this.store.size, maxKeys: this.config.maxKeys, cleanupIntervalMs: this.config.cleanupIntervalMs, memory: this.getMemoryUsage(), rollingWindow: this.getRollingWindowStats(), timeBased: this.getTimeBasedStats(), config: { ...this.config }, }; } /** * Calculate the size of a value in bytes * @param {any} value - The value to measure * @returns {number} Size in bytes * @private */ _getValueSize(value) { try { // Use a circular reference-safe serializer const seen = new WeakSet(); const jsonString = JSON.stringify(value, (key, val) => { if (typeof val === 'object' && val !== null) { if (seen.has(val)) { return '[Circular]'; } seen.add(val); } return val; }); // Calculate actual byte size using Buffer.byteLength for accurate UTF-8 encoding return Buffer.byteLength(jsonString, 'utf8'); } catch { return 0; // Fallback for non-serializable values } } /** * Get approximate memory usage of cache * @returns {Object} Memory usage information */ getMemoryUsage() { let totalSize = 0; for (const [key, entry] of this.store.entries()) { // Use consistent size calculation methods totalSize += Buffer.byteLength(key, 'utf8'); // Accurate key size totalSize += this._getValueSize(entry.value); // Use existing method for value size totalSize += 24; // Overhead for timestamps and metadata } return { bytes: totalSize, kb: (totalSize / 1024).toFixed(2), mb: (totalSize / (1024 * 1024)).toFixed(4), }; } /** * Destroy the cache and cleanup resources */ destroy() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } this.clear(); } } // Global cache instance let globalCache = null; /** * Get the global cache instance * @param {Object} options - Cache configuration options (only used on first call) * @returns {Cache} Cache instance */ export function getCache(options = {}) { if (!globalCache) { globalCache = new Cache(options); } return globalCache; } /** * Reset the global cache instance (useful for testing) * Clears the existing cache and allows a fresh instance to be created */ export function resetCache() { if (globalCache) { globalCache.destroy(); globalCache = null; } } /** * Cache a ccusage command result * @param {string} command - The command that was executed * @param {string} result - The command result to cache * @param {number} ttlMs - TTL in milliseconds (default: 15000) */ export function cacheCommandResult(command, result, ttlMs = 15000) { const cache = getCache(); const key = `ccusage_command:${command}`; cache.set(key, result, ttlMs); } /** * Get cached ccusage command result * @param {string} command - The command to look up * @returns {string|null} Cached result or null if not found */ export function getCachedCommandResult(command) { const cache = getCache(); const key = `ccusage_command:${command}`; return cache.get(key); } /** * Get cache statistics for debugging * @returns {Object} Cache statistics */ export function getCacheStats() { const cache = getCache(); return cache.getStats(); } /** * Clear all cache entries */ export function clearCache() { const cache = getCache(); cache.clear(); } /** * Destroy the global cache (useful for testing) */ export function destroyCache() { if (globalCache) { globalCache.destroy(); globalCache = null; } } /** * Add event listeners to cache for debugging and monitoring * This is an example of how to use the cache event system * @param {Cache} cache - Cache instance to monitor * @param {Object} options - Monitoring options * @param {boolean} options.logSet - Log set operations (default: true) * @param {boolean} options.logGet - Log get operations (default: false) * @param {boolean} options.logEvictions - Log eviction events (default: true) * @param {boolean} options.logCleanup - Log cleanup events (default: true) * @param {boolean} options.logClear - Log clear events (default: true) */ export function addCacheEventLogging(cache, options = {}) { const opts = { logSet: true, logGet: false, // Can be noisy logEvictions: true, logCleanup: true, logClear: true, ...options, }; if (opts.logSet) { cache.on('set', (data) => { console.log(`[Cache] SET key="${data.key}" size=${data.valueSize}B ttl=${data.ttl}ms`); }); } if (opts.logGet) { cache.on('get', (data) => { const hitMiss = data.hit ? 'HIT' : 'MISS'; console.log(`[Cache] GET key="${data.key}" result=${hitMiss}`); }); } if (opts.logEvictions) { cache.on('eviction', (data) => { console.log(`[Cache] EVICTION key="${data.key}" reason=${data.reason}`); }); } if (opts.logCleanup) { cache.on('cleanup', (data) => { console.log(`[Cache] CLEANUP evicted ${data.evictedCount} expired entries`); }); } if (opts.logClear) { cache.on('clear', (data) => { console.log(`[Cache] CLEAR removed ${data.keysCleared} entries`); }); } } /** * Create a cache statistics collector using events * This shows how to use events for monitoring cache performance * @param {Cache} cache - Cache instance to monitor * @returns {Object} Statistics collector with getStats() method */ export function createCacheStatsCollector(cache) { const stats = { operations: { sets: 0, gets: 0, hits: 0, misses: 0, evictions: { ttl: 0, lru: 0, manual: 0 }, cleanups: 0, clears: 0, }, recentOperations: [], // Last 20 operations }; // Track set operations cache.on('set', (data) => { stats.operations.sets++; stats.recentOperations.push({ type: 'set', key: data.key, timestamp: data.timestamp, }); trimRecentOperations(); }); // Track get operations cache.on('get', (data) => { stats.operations.gets++; if (data.hit) { stats.operations.hits++; } else { stats.operations.misses++; } stats.recentOperations.push({ type: data.hit ? 'hit' : 'miss', key: data.key, timestamp: data.timestamp, }); trimRecentOperations(); }); // Track evictions cache.on('eviction', (data) => { stats.operations.evictions[data.reason]++; stats.recentOperations.push({ type: 'eviction', key: data.key, reason: data.reason, timestamp: data.timestamp, }); trimRecentOperations(); }); // Track cleanup operations cache.on('cleanup', (data) => { stats.operations.cleanups++; stats.recentOperations.push({ type: 'cleanup', evictedCount: data.evictedCount, timestamp: data.timestamp, }); trimRecentOperations(); }); // Track clear operations cache.on('clear', (data) => { stats.operations.clears++; stats.recentOperations.push({ type: 'clear', keysCleared: data.keysCleared, timestamp: data.timestamp, }); trimRecentOperations(); }); function trimRecentOperations() { if (stats.recentOperations.length > 20) { stats.recentOperations = stats.recentOperations.slice(-20); } } return { getStats: () => ({ ...stats }), reset: () => { stats.operations = { sets: 0, gets: 0, hits: 0, misses: 0, evictions: { ttl: 0, lru: 0, manual: 0 }, cleanups: 0, clears: 0, }; stats.recentOperations = []; }, }; }