claude-usage-tracker
Version:
Advanced analytics for Claude Code usage with cost optimization, conversation length analysis, and rate limit tracking
436 lines ⢠20.2 kB
JavaScript
import { watch } from "node:fs";
import { stat } from "node:fs/promises";
import chalk from "chalk";
import { calculateCost, getCurrentWeekUsage } from "./analyzer.js";
import { CLAUDE_DATA_PATHS } from "./config.js";
import { IncrementalDataLoader } from "./incremental-loader.js";
import { TerminalCharts } from "./terminal-charts.js";
export class UsageWatcher {
lastStats = null;
watchers = [];
isWatching = false;
updateInterval = null;
conversationHistory = [];
lastProcessedTime = new Date();
dataLoader = new IncrementalDataLoader();
allEntries = []; // Cache all entries for stats calculation
tokenHistory = []; // Track token usage over time for sparkline
hourlyUsage = Array(24).fill(0); // Track usage by hour
dailyCosts = []; // Track daily costs for weekly trend
fiveMinuteTokens = []; // Track tokens per 5-minute interval
async startWatching(callback) {
if (this.isWatching)
return;
this.isWatching = true;
this.lastProcessedTime = new Date();
console.log(chalk.dim("Starting live usage monitoring..."));
console.log(chalk.dim("Watching directories:"));
// Setup file watchers for all Claude data paths
for (const dataPath of CLAUDE_DATA_PATHS) {
try {
const stats = await stat(dataPath);
if (stats.isDirectory()) {
console.log(chalk.dim(` š ${dataPath}`));
const watcher = watch(dataPath, { recursive: true }, async (eventType, filename) => {
if (filename?.endsWith(".jsonl")) {
console.log(chalk.dim(`š File change detected: ${eventType} on ${filename}`));
await this.handleFileChange();
}
});
this.watchers.push(watcher);
}
}
catch (_error) {
// Directory doesn't exist, skip silently
}
}
// Initial load - get all historical data
console.log(chalk.dim("Loading historical data..."));
this.allEntries = await this.dataLoader.loadAllData();
console.log(chalk.dim(`Loaded ${this.allEntries.length} historical entries`));
// Initialize hourly usage from today's data
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayEntries = this.allEntries.filter(entry => new Date(entry.timestamp) >= todayStart);
// Reset hourly usage and populate with today's data
this.hourlyUsage = Array(24).fill(0);
for (const entry of todayEntries) {
const hour = new Date(entry.timestamp).getHours();
this.hourlyUsage[hour] += entry.total_tokens;
}
// Initialize token history with recent activity (last 2 hours in 5-minute intervals)
this.initializeTokenHistory();
// Initialize daily costs for the week
this.initializeDailyCosts();
// Initial stats calculation
await this.updateStats();
if (this.lastStats) {
callback(this.lastStats, this.conversationHistory.slice(-5));
}
// Regular updates every 5 seconds for more responsive display
this.updateInterval = setInterval(async () => {
await this.updateStats();
if (this.lastStats) {
callback(this.lastStats, this.conversationHistory.slice(-5));
}
}, 5000);
console.log(chalk.green("ā
Live monitoring started"));
}
async handleFileChange() {
try {
// Debounce rapid file changes
await new Promise((resolve) => setTimeout(resolve, 200));
// Get only new entries using incremental loader
const newEntries = await this.dataLoader.loadNewEntries();
if (newEntries.length > 0) {
console.log(chalk.yellow(`\nš„ Processing ${newEntries.length} new entries...`));
// Add new entries to our cache
this.allEntries.push(...newEntries);
// Sort to maintain chronological order
this.allEntries.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
// Memory management: keep only last 30 days of data in memory
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
this.allEntries = this.allEntries.filter(entry => new Date(entry.timestamp) >= thirtyDaysAgo);
// Process new entries for conversation history
for (const entry of newEntries) {
const conversationEvent = {
conversationId: entry.conversationId,
model: entry.model,
cost: calculateCost(entry),
tokens: entry.total_tokens,
timestamp: new Date(entry.timestamp),
efficiency: this.calculateEfficiency(entry),
};
this.conversationHistory.push(conversationEvent);
// Keep only last 50 conversations in memory
if (this.conversationHistory.length > 50) {
this.conversationHistory = this.conversationHistory.slice(-50);
}
}
// Debug: show what was added
const totalNewCost = newEntries.reduce((sum, e) => sum + calculateCost(e), 0);
const totalNewTokens = newEntries.reduce((sum, e) => sum + e.total_tokens, 0);
console.log(chalk.green(`ā
Added ${newEntries.length} entries, cost: $${totalNewCost.toFixed(4)}`));
console.log(chalk.blue(`š Total entries in memory: ${this.allEntries.length}`));
// Update token history for sparkline
this.tokenHistory.push(totalNewTokens);
if (this.tokenHistory.length > 50) {
this.tokenHistory = this.tokenHistory.slice(-50);
}
// Update hourly usage
for (const entry of newEntries) {
const hour = new Date(entry.timestamp).getHours();
this.hourlyUsage[hour] += entry.total_tokens;
}
this.lastProcessedTime = new Date();
await this.updateStats();
}
}
catch (_error) {
// Silently handle file reading errors during updates
console.error(chalk.red("Error processing file change:"), _error);
}
}
calculateEfficiency(entry) {
const cost = calculateCost(entry);
const tokensPerDollar = entry.total_tokens / Math.max(cost, 0.001);
// Efficiency thresholds based on model expectations
const isOpus = entry.model.includes("opus");
const thresholds = isOpus
? { high: 8000, medium: 4000 } // Opus thresholds
: { high: 15000, medium: 8000 }; // Sonnet thresholds
if (tokensPerDollar > thresholds.high)
return "high";
if (tokensPerDollar > thresholds.medium)
return "medium";
return "low";
}
async updateStats() {
try {
const entries = this.allEntries; // Use cached entries
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
// Today's stats
const todayEntries = entries.filter((entry) => new Date(entry.timestamp) >= todayStart);
const todayTotal = todayEntries.reduce((sum, e) => sum + e.total_tokens, 0);
const todayCost = todayEntries.reduce((sum, e) => sum + calculateCost(e), 0);
const todayConversations = new Set(todayEntries.map((e) => e.conversationId)).size;
// Week stats
const weekUsage = getCurrentWeekUsage(entries);
// Last conversation
const sortedEntries = entries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
const lastEntry = sortedEntries[0];
const lastConversationCost = lastEntry ? calculateCost(lastEntry) : 0;
const lastConversationModel = lastEntry ? lastEntry.model : "N/A";
// Calculate burn rate (compared to last 7 days average)
const last7Days = entries.filter((entry) => {
const entryDate = new Date(entry.timestamp);
const cutoff = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
return entryDate >= cutoff && entryDate < todayStart;
});
const avgDailyCost = last7Days.length > 0
? last7Days.reduce((sum, e) => sum + calculateCost(e), 0) / 7
: 0;
const burnRate = avgDailyCost > 0
? ((todayCost - avgDailyCost) / avgDailyCost) * 100
: 0;
this.lastStats = {
todayTotal,
todayCost,
weekTotal: weekUsage.totalTokens,
weekCost: weekUsage.cost,
lastConversationCost,
lastConversationModel,
burnRate,
totalConversationsToday: todayConversations,
averageCostPerConversation: todayConversations > 0 ? todayCost / todayConversations : 0,
lastUpdated: new Date(),
};
}
catch (_error) {
console.error(chalk.red("Error updating stats:"), _error);
}
}
stopWatching() {
if (!this.isWatching)
return;
this.isWatching = false;
// Close all file watchers
for (const watcher of this.watchers) {
watcher.close();
}
this.watchers = [];
// Clear update interval
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
// Clear cache to free memory
this.allEntries = [];
this.conversationHistory = [];
this.dataLoader.clearCache();
console.log(chalk.yellow("ā¹ļø Live monitoring stopped"));
}
formatLiveDisplay(stats, recentConversations) {
const now = new Date();
let output = "";
// Clear screen and move to top
output += "\x1b[2J\x1b[H";
// Compact header
output += chalk.blue.bold("CLAUDE USAGE MONITOR ") + chalk.gray(`${now.toLocaleTimeString()}\n`);
output += chalk.gray("ā".repeat(60)) + "\n\n";
// Main stats in a compact grid
const projections = this.calculateProjections(stats);
// Row 1: Today & Week
output += chalk.cyan("Today: ") + chalk.white(`$${stats.todayCost.toFixed(2)}`);
output += chalk.gray(" | ");
output += chalk.cyan("Week: ") + chalk.white(`$${stats.weekCost.toFixed(2)}`);
output += chalk.gray(" | ");
output += chalk.cyan("Month proj: ") + chalk.yellow(`$${projections.monthlyProjection.toFixed(0)}`);
// Add warning icon if spending is high
if (projections.monthlyProjection > 1000) {
output += chalk.red(" ā ļø");
}
output += "\n";
// Row 2: Model split & burn rate
const modelUsage = this.getModelUsageDistribution();
if (modelUsage.length > 0) {
const sonnetPct = modelUsage.find(m => m.label === "Sonnet 4")?.value || 0;
const opusPct = modelUsage.find(m => m.label === "Opus 4")?.value || 0;
const totalTokens = sonnetPct + opusPct;
if (totalTokens > 0) {
const sonnetPercent = Math.round((sonnetPct / totalTokens) * 100);
const opusPercent = Math.round((opusPct / totalTokens) * 100);
output += chalk.blue("S4: ") + chalk.white(`${sonnetPercent}%`);
output += chalk.gray(" | ");
output += chalk.magenta("O4: ") + chalk.white(`${opusPercent}%`);
}
}
output += chalk.gray(" | ");
const burnRateColor = stats.burnRate > 20 ? chalk.red : stats.burnRate > 0 ? chalk.yellow : chalk.green;
output += chalk.cyan("Burn: ") + burnRateColor(`${stats.burnRate > 0 ? "+" : ""}${stats.burnRate.toFixed(0)}%`);
output += "\n\n";
// Token usage sparkline - more compact
output += chalk.cyan("Activity: ");
const sparkline = TerminalCharts.sparkline(this.tokenHistory.length > 0 ? this.tokenHistory : [0], 40, {
color: (value, max) => {
if (value > max * 0.8)
return chalk.red;
if (value > max * 0.5)
return chalk.yellow;
return chalk.green;
},
});
output += sparkline + "\n";
// Hourly heat map - single line
output += chalk.cyan("24h pattern: ");
const hourlySparkline = TerminalCharts.sparkline(this.hourlyUsage, 24, {
color: (value, max) => {
if (value === 0)
return chalk.gray;
if (value > max * 0.7)
return chalk.red;
if (value > max * 0.4)
return chalk.yellow;
return chalk.green;
},
});
output += hourlySparkline + "\n\n";
// Recent activity - compact view
if (recentConversations.length > 0) {
output += chalk.cyan("Recent: ");
const recent = recentConversations.slice(-3).reverse();
recent.forEach((conv, i) => {
if (i > 0)
output += " ā ";
const model = conv.model.includes("opus") ? "O4" : "S4";
const modelColor = conv.model.includes("opus") ? chalk.magenta : chalk.blue;
output += modelColor(model) + chalk.gray(`/$${conv.cost.toFixed(2)}`);
});
output += "\n\n";
}
// Only show critical warnings
if (projections.monthlyProjection > 1000) {
output += chalk.red.bold("ā ļø BUDGET ALERT: ") +
chalk.red(`Monthly projection: $${projections.monthlyProjection.toFixed(0)}\n`);
}
if (stats.weekCost > 200) {
output += chalk.yellow.bold("ā ļø HIGH USAGE: ") +
chalk.yellow(`This week already at $${stats.weekCost.toFixed(0)}\n`);
}
if (stats.burnRate > 50) {
output += chalk.red.bold("š„ BURN RATE: ") +
chalk.red(`${stats.burnRate.toFixed(0)}% above average\n`);
}
// Spacer before controls
output += "\n";
// Controls
output += chalk.gray("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā\n");
output += chalk.gray("Press Ctrl+C to stop monitoring\n");
return output;
}
formatTokens(tokens) {
if (tokens >= 1_000_000) {
return `${(tokens / 1_000_000).toFixed(2)}M`;
}
else if (tokens >= 1_000) {
return `${(tokens / 1_000).toFixed(1)}K`;
}
return tokens.toString();
}
getTimeAgo(timestamp) {
const now = new Date();
const diffMs = now.getTime() - timestamp.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
if (diffMins < 1)
return "now";
if (diffMins === 1)
return "1min ago";
if (diffMins < 60)
return `${diffMins}min ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours === 1)
return "1hr ago";
if (diffHours < 24)
return `${diffHours}hr ago`;
return timestamp.toLocaleDateString();
}
getModelUsageDistribution() {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayEntries = this.allEntries.filter((entry) => new Date(entry.timestamp) >= todayStart);
const sonnetTokens = todayEntries
.filter((e) => e.model.includes("sonnet"))
.reduce((sum, e) => sum + e.total_tokens, 0);
const opusTokens = todayEntries
.filter((e) => e.model.includes("opus"))
.reduce((sum, e) => sum + e.total_tokens, 0);
const totalTokens = sonnetTokens + opusTokens;
if (totalTokens === 0)
return [];
return [
{
label: "Sonnet 4",
value: sonnetTokens,
color: chalk.blue,
},
{
label: "Opus 4",
value: opusTokens,
color: chalk.magenta,
},
];
}
initializeTokenHistory() {
const now = new Date();
const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000);
// Create 5-minute buckets for the last 2 hours
const buckets = Array(24).fill(0); // 24 five-minute intervals
const recentEntries = this.allEntries.filter(entry => new Date(entry.timestamp) >= twoHoursAgo);
recentEntries.forEach(entry => {
const entryTime = new Date(entry.timestamp);
const minutesAgo = Math.floor((now.getTime() - entryTime.getTime()) / (1000 * 60));
const bucketIndex = Math.floor(minutesAgo / 5);
if (bucketIndex >= 0 && bucketIndex < 24) {
buckets[23 - bucketIndex] += entry.total_tokens;
}
});
// Store non-zero buckets to create a meaningful sparkline
this.fiveMinuteTokens = buckets;
this.tokenHistory = buckets.filter(tokens => tokens > 0);
// If no recent activity, add some sample data to show the sparkline
if (this.tokenHistory.length === 0) {
this.tokenHistory = [0];
}
}
initializeDailyCosts() {
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
// Group entries by day
const dailyMap = new Map();
this.allEntries
.filter(entry => new Date(entry.timestamp) >= sevenDaysAgo)
.forEach(entry => {
const date = new Date(entry.timestamp).toISOString().split('T')[0];
const cost = calculateCost(entry);
dailyMap.set(date, (dailyMap.get(date) || 0) + cost);
});
// Convert to array and sort by date
this.dailyCosts = Array.from(dailyMap.entries())
.map(([date, cost]) => ({ date, cost }))
.sort((a, b) => a.date.localeCompare(b.date));
}
updateFiveMinuteTokens(newTokens) {
// Shift array and add new value
this.fiveMinuteTokens.shift();
this.fiveMinuteTokens.push(newTokens);
// Update token history for sparkline
this.tokenHistory = this.fiveMinuteTokens.filter(tokens => tokens > 0);
if (this.tokenHistory.length === 0) {
this.tokenHistory = [0];
}
// Keep reasonable size
if (this.tokenHistory.length > 50) {
this.tokenHistory = this.tokenHistory.slice(-50);
}
}
calculateProjections(stats) {
// Calculate daily average from the last 7 days
const dailyAverage = this.dailyCosts.length > 0
? this.dailyCosts.reduce((sum, d) => sum + d.cost, 0) / this.dailyCosts.length
: stats.todayCost;
// Project based on current burn rate if significantly different from average
const adjustedDaily = stats.burnRate > 20
? dailyAverage * (1 + stats.burnRate / 100)
: stats.burnRate < -20
? dailyAverage * (1 + stats.burnRate / 100)
: dailyAverage;
return {
dailyAverage,
weeklyProjection: adjustedDaily * 7,
monthlyProjection: adjustedDaily * 30,
};
}
}
//# sourceMappingURL=watch-monitor.js.map