mcp-product-manager
Version:
MCP Orchestrator for task and project management with web interface
531 lines • 25 kB
JavaScript
// UsageTrackingService - Handles ccusage integration with proper block boundary tracking
// Combines ccusage blocks (for 5-hour boundaries) with ccusage daily (for token details)
import { exec, execSync } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
export class UsageTrackingService {
constructor(dbPath) {
this.dbPath = dbPath;
this.db = null;
this.cache = new Map();
this.lastBlockId = null;
this.blockCheckInterval = null;
this.isInitialized = false;
// Cache TTLs
this.CACHE_TTL = {
active: 60 * 1000, // 1 minute for active block
recent: 5 * 60 * 1000, // 5 minutes for recent blocks
daily: 30 * 60 * 1000, // 30 minutes for daily stats
snapshot: 2 * 60 * 1000 // 2 minutes for snapshots
};
// Configuration
this.config = {
dailyBudget: 500,
warningThresholds: {
burnRate: 100, // $/hr warning threshold
timeRemaining: 4, // Hours warning threshold
opusUsage: 0.8 // 80% Opus usage warning
}
};
}
async initialize() {
// Initialize database connection with compatibility check
try {
const { default: Database } = await import('better-sqlite3');
this.db = new Database(this.dbPath);
}
catch (error) {
if (error.code === 'MODULE_NOT_FOUND' || error.message.includes('better-sqlite3')) {
console.log('🔧 Installing better-sqlite3 for usage tracking...');
try {
execSync('npm install better-sqlite3', {
stdio: 'inherit',
cwd: process.cwd()
});
console.log('✅ better-sqlite3 installed successfully');
// Try importing again after install
const { default: Database } = await import('better-sqlite3');
this.db = new Database(this.dbPath);
}
catch (installError) {
console.error('❌ Failed to install better-sqlite3 for usage tracking:', installError.message);
throw installError;
}
}
else if (error.message.includes('NODE_MODULE_VERSION')) {
console.log('🔧 Rebuilding better-sqlite3 for usage tracking...');
try {
execSync('npm rebuild better-sqlite3', {
stdio: 'inherit',
cwd: process.cwd()
});
console.log('✅ better-sqlite3 rebuilt successfully');
// Try importing again after rebuild
const { default: Database } = await import('better-sqlite3');
this.db = new Database(this.dbPath);
}
catch (rebuildError) {
console.error('❌ Failed to rebuild better-sqlite3 for usage tracking:', rebuildError.message);
throw rebuildError;
}
}
else {
console.error('❌ Database error in usage tracking:', error.message);
throw error;
}
}
// Ensure snapshot tables exist
await this.createSnapshotTables();
// Capture initial baseline for current block
await this.captureInitialBaseline();
// Start monitoring for block transitions
this.startBlockMonitoring();
this.isInitialized = true;
console.log('✅ UsageTrackingService initialized');
}
async createSnapshotTables() {
try {
this.db.exec(`
CREATE TABLE IF NOT EXISTS block_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
block_start_time TEXT NOT NULL,
block_id TEXT NOT NULL,
snapshot_time TEXT NOT NULL,
reason TEXT NOT NULL,
cumulative_total_tokens INTEGER,
cumulative_total_cost REAL,
cumulative_opus_tokens INTEGER,
cumulative_opus_cost REAL,
cumulative_opus_input_tokens INTEGER DEFAULT 0,
cumulative_opus_output_tokens INTEGER DEFAULT 0,
cumulative_opus_cache_creation_tokens INTEGER DEFAULT 0,
cumulative_opus_cache_read_tokens INTEGER DEFAULT 0,
cumulative_sonnet_tokens INTEGER,
cumulative_sonnet_cost REAL,
cumulative_sonnet_input_tokens INTEGER DEFAULT 0,
cumulative_sonnet_output_tokens INTEGER DEFAULT 0,
cumulative_sonnet_cache_creation_tokens INTEGER DEFAULT 0,
cumulative_sonnet_cache_read_tokens INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(block_start_time, reason)
)
`);
}
catch (err) {
throw err;
}
}
async captureInitialBaseline() {
try {
// Get current active block with its backdated start time
const { stdout: blockData } = await execAsync('npx ccusage blocks --active --json');
const blocks = JSON.parse(blockData);
const activeBlock = blocks.blocks?.[0];
if (!activeBlock) {
console.log('⚠️ No active block found');
return;
}
// CRITICAL: Use the backdated block start time from ccusage
const blockStartTime = activeBlock.startTime;
this.lastBlockId = activeBlock.id;
console.log(`📊 Current block: ${activeBlock.id} (started: ${blockStartTime})`);
// Check if we already have a baseline for this block
const existingBaseline = await this.getSnapshot(blockStartTime, 'block_start');
if (!existingBaseline) {
console.log(`📸 Capturing initial baseline for block starting at ${blockStartTime}`);
// Get detailed token data from daily
const todayStr = new Date().toISOString().split('T')[0].replace(/-/g, '');
const { stdout: dailyData } = await execAsync(`npx ccusage daily --since ${todayStr} --breakdown --json`);
const daily = JSON.parse(dailyData);
// Extract model breakdowns
const modelData = this.extractModelData(daily);
// Save baseline snapshot with backdated block start time
await this.saveSnapshot({
block_start_time: blockStartTime,
block_id: activeBlock.id,
snapshot_time: new Date().toISOString(),
reason: 'block_start',
...modelData
});
console.log('✅ Initial baseline captured');
}
else {
console.log('✓ Baseline already exists for current block');
}
// Calculate next block transition
const blockStart = new Date(blockStartTime);
const nextBlockTime = new Date(blockStart.getTime() + (5 * 60 * 60 * 1000));
console.log(`⏰ Next block transition expected at: ${nextBlockTime.toISOString()}`);
}
catch (error) {
console.error('❌ Error capturing initial baseline:', error);
}
}
extractModelData(dailyData) {
const daily = dailyData.daily?.[0] || {};
const modelBreakdowns = daily.modelBreakdowns || [];
// Find Opus and Sonnet data
const opusData = modelBreakdowns.find(m => m.modelName?.toLowerCase().includes('opus')) || {};
const sonnetData = modelBreakdowns.find(m => m.modelName?.toLowerCase().includes('sonnet')) || {};
return {
cumulative_total_tokens: daily.totalTokens || 0,
cumulative_total_cost: daily.totalCost || 0,
cumulative_opus_tokens: opusData.totalTokens || 0,
cumulative_opus_cost: opusData.cost || 0,
cumulative_opus_input_tokens: opusData.inputTokens || 0,
cumulative_opus_output_tokens: opusData.outputTokens || 0,
cumulative_opus_cache_creation_tokens: opusData.cacheCreationInputTokens || 0,
cumulative_opus_cache_read_tokens: opusData.cacheReadInputTokens || 0,
cumulative_sonnet_tokens: sonnetData.totalTokens || 0,
cumulative_sonnet_cost: sonnetData.cost || 0,
cumulative_sonnet_input_tokens: sonnetData.inputTokens || 0,
cumulative_sonnet_output_tokens: sonnetData.outputTokens || 0,
cumulative_sonnet_cache_creation_tokens: sonnetData.cacheCreationInputTokens || 0,
cumulative_sonnet_cache_read_tokens: sonnetData.cacheReadInputTokens || 0
};
}
startBlockMonitoring() {
// Check for block transitions every minute
this.blockCheckInterval = setInterval(async () => {
await this.checkBlockTransition();
}, 60 * 1000); // 1 minute
console.log('🔄 Block transition monitoring started');
}
async checkBlockTransition() {
try {
const { stdout } = await execAsync('npx ccusage blocks --active --json');
const data = JSON.parse(stdout);
const currentBlock = data.blocks?.[0];
if (currentBlock && currentBlock.id !== this.lastBlockId) {
console.log(`🔄 BLOCK TRANSITION DETECTED: ${this.lastBlockId} → ${currentBlock.id}`);
// Capture end-of-block snapshot for the old block
if (this.lastBlockId) {
await this.captureSnapshot('block_end');
}
// Update current block
this.lastBlockId = currentBlock.id;
// Capture start-of-block baseline for new block
await this.captureSnapshot('block_start');
// Clear relevant caches
this.cache.delete('active-block');
this.cache.delete('current-usage');
}
}
catch (error) {
console.error('Error checking block transition:', error);
}
}
async captureSnapshot(reason) {
try {
const { stdout: blockData } = await execAsync('npx ccusage blocks --active --json');
const blocks = JSON.parse(blockData);
const activeBlock = blocks.blocks?.[0];
if (!activeBlock)
return;
// Get detailed token data
const todayStr = new Date().toISOString().split('T')[0].replace(/-/g, '');
const { stdout: dailyData } = await execAsync(`npx ccusage daily --since ${todayStr} --breakdown --json`);
const daily = JSON.parse(dailyData);
const modelData = this.extractModelData(daily);
await this.saveSnapshot({
block_start_time: activeBlock.startTime,
block_id: activeBlock.id,
snapshot_time: new Date().toISOString(),
reason: reason,
...modelData
});
console.log(`📸 Snapshot captured: ${reason} for block ${activeBlock.id}`);
}
catch (error) {
console.error('Error capturing snapshot:', error);
}
}
async saveSnapshot(data) {
try {
const sql = `
INSERT OR REPLACE INTO block_snapshots (
block_start_time, block_id, snapshot_time, reason,
cumulative_total_tokens, cumulative_total_cost,
cumulative_opus_tokens, cumulative_opus_cost,
cumulative_opus_input_tokens, cumulative_opus_output_tokens,
cumulative_opus_cache_creation_tokens, cumulative_opus_cache_read_tokens,
cumulative_sonnet_tokens, cumulative_sonnet_cost,
cumulative_sonnet_input_tokens, cumulative_sonnet_output_tokens,
cumulative_sonnet_cache_creation_tokens, cumulative_sonnet_cache_read_tokens
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
const stmt = this.db.prepare(sql);
stmt.run([
data.block_start_time, data.block_id, data.snapshot_time, data.reason,
data.cumulative_total_tokens, data.cumulative_total_cost,
data.cumulative_opus_tokens, data.cumulative_opus_cost,
data.cumulative_opus_input_tokens, data.cumulative_opus_output_tokens,
data.cumulative_opus_cache_creation_tokens, data.cumulative_opus_cache_read_tokens,
data.cumulative_sonnet_tokens, data.cumulative_sonnet_cost,
data.cumulative_sonnet_input_tokens, data.cumulative_sonnet_output_tokens,
data.cumulative_sonnet_cache_creation_tokens, data.cumulative_sonnet_cache_read_tokens
]);
}
catch (err) {
throw err;
}
}
async getSnapshot(blockStartTime, reason) {
try {
const stmt = this.db.prepare('SELECT * FROM block_snapshots WHERE block_start_time = ? AND reason = ?');
return stmt.get([blockStartTime, reason]);
}
catch (err) {
throw err;
}
}
// Public API methods
async getCurrentUsage() {
const cacheKey = 'current-usage';
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL.active) {
return cached.data;
}
try {
// Get active block info
const { stdout: blockData } = await execAsync('npx ccusage blocks --active --breakdown --json');
const blocks = JSON.parse(blockData);
const activeBlock = blocks.blocks?.[0];
if (!activeBlock) {
return { error: 'No active block found' };
}
// Get detailed daily data for model breakdown
const todayStr = new Date().toISOString().split('T')[0].replace(/-/g, '');
const { stdout: dailyData } = await execAsync(`npx ccusage daily --since ${todayStr} --breakdown --json`);
const daily = JSON.parse(dailyData);
// Get baseline snapshot for this block
const baseline = await this.getSnapshot(activeBlock.startTime, 'block_start');
// Calculate current usage within block
const currentModelData = this.extractModelData(daily);
const blockUsage = this.calculateBlockUsage(currentModelData, baseline);
// Calculate burn rate and projections
const analysis = this.analyzeUsage(activeBlock, blockUsage);
const result = {
block: activeBlock,
usage: blockUsage,
analysis: analysis,
timestamp: new Date().toISOString()
};
this.cache.set(cacheKey, {
data: result,
timestamp: Date.now()
});
return result;
}
catch (error) {
console.error('Error getting current usage:', error);
return { error: error.message };
}
}
calculateBlockUsage(current, baseline) {
if (!baseline) {
// No baseline means this is all usage for current block
return {
opus: {
tokens: current.cumulative_opus_tokens,
cost: current.cumulative_opus_cost,
inputTokens: current.cumulative_opus_input_tokens,
outputTokens: current.cumulative_opus_output_tokens,
cacheTokens: current.cumulative_opus_cache_creation_tokens + current.cumulative_opus_cache_read_tokens
},
sonnet: {
tokens: current.cumulative_sonnet_tokens,
cost: current.cumulative_sonnet_cost,
inputTokens: current.cumulative_sonnet_input_tokens,
outputTokens: current.cumulative_sonnet_output_tokens,
cacheTokens: current.cumulative_sonnet_cache_creation_tokens + current.cumulative_sonnet_cache_read_tokens
},
total: {
tokens: current.cumulative_total_tokens,
cost: current.cumulative_total_cost
}
};
}
// Calculate delta from baseline
return {
opus: {
tokens: current.cumulative_opus_tokens - baseline.cumulative_opus_tokens,
cost: current.cumulative_opus_cost - baseline.cumulative_opus_cost,
inputTokens: current.cumulative_opus_input_tokens - baseline.cumulative_opus_input_tokens,
outputTokens: current.cumulative_opus_output_tokens - baseline.cumulative_opus_output_tokens,
cacheTokens: (current.cumulative_opus_cache_creation_tokens + current.cumulative_opus_cache_read_tokens) -
(baseline.cumulative_opus_cache_creation_tokens + baseline.cumulative_opus_cache_read_tokens)
},
sonnet: {
tokens: current.cumulative_sonnet_tokens - baseline.cumulative_sonnet_tokens,
cost: current.cumulative_sonnet_cost - baseline.cumulative_sonnet_cost,
inputTokens: current.cumulative_sonnet_input_tokens - baseline.cumulative_sonnet_input_tokens,
outputTokens: current.cumulative_sonnet_output_tokens - baseline.cumulative_sonnet_output_tokens,
cacheTokens: (current.cumulative_sonnet_cache_creation_tokens + current.cumulative_sonnet_cache_read_tokens) -
(baseline.cumulative_sonnet_cache_creation_tokens + baseline.cumulative_sonnet_cache_read_tokens)
},
total: {
tokens: current.cumulative_total_tokens - baseline.cumulative_total_tokens,
cost: current.cumulative_total_cost - baseline.cumulative_total_cost
}
};
}
analyzeUsage(block, usage) {
const elapsedMinutes = block.elapsedMinutes || 1;
const burnRate = (usage.total.cost / elapsedMinutes) * 60; // $/hour
const budgetRemaining = this.config.dailyBudget - usage.total.cost;
const hoursUntilBudgetExhausted = burnRate > 0 ? budgetRemaining / burnRate : Infinity;
const opusRatio = usage.total.cost > 0 ? usage.opus.cost / usage.total.cost : 0;
const warnings = [];
// Budget warnings
if (hoursUntilBudgetExhausted < 2) {
warnings.push({
severity: 'critical',
type: 'budget',
message: 'Less than 2 hours until budget exhausted',
action: 'Pause non-critical agents immediately'
});
}
else if (hoursUntilBudgetExhausted < 4) {
warnings.push({
severity: 'warning',
type: 'budget',
message: `Budget will be exhausted in ${hoursUntilBudgetExhausted.toFixed(1)} hours`,
action: 'Monitor closely and prioritize critical tasks'
});
}
// Burn rate warnings
if (burnRate > this.config.warningThresholds.burnRate) {
warnings.push({
severity: 'warning',
type: 'burn_rate',
message: `High burn rate: $${burnRate.toFixed(2)}/hour`,
action: 'Consider using Sonnet instead of Opus for analysis tasks'
});
}
// Model usage warnings
if (opusRatio > this.config.warningThresholds.opusUsage) {
warnings.push({
severity: 'warning',
type: 'model_usage',
message: `Heavy Opus usage: ${(opusRatio * 100).toFixed(0)}% of costs`,
action: 'Opus is 5x more expensive - use strategically for complex tasks only'
});
}
return {
burnRate: burnRate,
budgetRemaining: budgetRemaining,
hoursUntilBudgetExhausted: hoursUntilBudgetExhausted,
opusRatio: opusRatio,
sonnetRatio: 1 - opusRatio,
warnings: warnings,
recommendations: this.generateRecommendations(usage, burnRate, opusRatio)
};
}
generateRecommendations(usage, burnRate, opusRatio) {
const recommendations = [];
if (opusRatio > 0.5 && burnRate > 50) {
recommendations.push({
type: 'model_optimization',
priority: 'high',
suggestion: 'Switch routine tasks to Sonnet to reduce costs by up to 80%',
estimatedSavings: usage.opus.cost * 0.8
});
}
if (burnRate > 100) {
const optimalAgentCount = Math.floor(this.config.dailyBudget / (8 * 25)); // 8 hours, $25/hr per agent
recommendations.push({
type: 'agent_optimization',
priority: 'medium',
suggestion: `Consider limiting to ${optimalAgentCount} concurrent agents`,
estimatedSavings: (burnRate - (optimalAgentCount * 25)) * 8
});
}
return recommendations;
}
async getHistoricalTrends(days = 7) {
const cacheKey = `trends-${days}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL.daily) {
return cached.data;
}
try {
const since = new Date();
since.setDate(since.getDate() - days);
const sinceStr = since.toISOString().split('T')[0].replace(/-/g, '');
// Get daily breakdown
const { stdout: dailyData } = await execAsync(`npx ccusage daily --since ${sinceStr} --breakdown --json`);
const daily = JSON.parse(dailyData);
// Get recent blocks
const { stdout: blockData } = await execAsync('npx ccusage blocks --recent --breakdown --json');
const blocks = JSON.parse(blockData);
const result = {
daily: daily.daily || [],
blocks: blocks.blocks || [],
patterns: this.analyzePatterns(daily.daily || []),
timestamp: new Date().toISOString()
};
this.cache.set(cacheKey, {
data: result,
timestamp: Date.now()
});
return result;
}
catch (error) {
console.error('Error getting historical trends:', error);
return { error: error.message };
}
}
analyzePatterns(dailyData) {
if (!dailyData.length)
return {};
const totalCost = dailyData.reduce((sum, day) => sum + (day.totalCost || 0), 0);
const averageDailyCost = totalCost / dailyData.length;
let totalOpusCost = 0;
let totalSonnetCost = 0;
dailyData.forEach(day => {
if (day.modelBreakdowns) {
day.modelBreakdowns.forEach(model => {
if (model.modelName?.toLowerCase().includes('opus')) {
totalOpusCost += model.cost || 0;
}
else if (model.modelName?.toLowerCase().includes('sonnet')) {
totalSonnetCost += model.cost || 0;
}
});
}
});
return {
averageDailyCost: averageDailyCost,
totalCost: totalCost,
opusCost: totalOpusCost,
sonnetCost: totalSonnetCost,
opusPercentage: totalCost > 0 ? (totalOpusCost / totalCost) * 100 : 0,
trend: this.calculateTrend(dailyData)
};
}
calculateTrend(dailyData) {
if (dailyData.length < 3)
return 'insufficient_data';
const recent = dailyData.slice(-3);
const older = dailyData.slice(0, 3);
const recentAvg = recent.reduce((sum, d) => sum + d.totalCost, 0) / 3;
const olderAvg = older.reduce((sum, d) => sum + d.totalCost, 0) / 3;
if (recentAvg > olderAvg * 1.2)
return 'increasing';
if (recentAvg < olderAvg * 0.8)
return 'decreasing';
return 'stable';
}
// Cleanup method
stop() {
if (this.blockCheckInterval) {
clearInterval(this.blockCheckInterval);
}
if (this.db) {
this.db.close();
}
console.log('🛑 UsageTrackingService stopped');
}
}
//# sourceMappingURL=usageTracking.js.map