Add SDK usage tracking to JSONL logs
Features: - New UsageLogger utility that writes usage metrics to daily JSONL files - Captures token counts, costs, timing, and cache metrics from SDK result messages - Usage logs stored in ~/.claude-mem/usage-logs/ (one file per day) - Added analyze-usage.js script for analyzing usage patterns Usage data captured: - Token counts (input, output, cache creation, cache read) - Total cost in USD per API call - Duration metrics (total and API time) - Number of turns per session - Session and project attribution Analysis script features: - Aggregates totals by project and model - Shows cache hit rates and savings - Displays cost breakdowns and averages - npm scripts: usage:analyze and usage:today Files: - src/utils/usage-logger.ts (new) - src/services/worker-service.ts (modified - captures SDK result messages) - scripts/analyze-usage.js (new) - package.json (added usage:* npm scripts) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
For tracking costs and tokens in your Agent SDK plugin, you have built-in programmatic access to usage data through the SDK itself[(1)](https://docs.claude.com/en/api/agent-sdk/cost-tracking).
|
||||
|
||||
## Agent SDK Cost Tracking
|
||||
|
||||
The Claude Agent SDK provides detailed token usage information for each interaction[(1)](https://docs.claude.com/en/api/agent-sdk/cost-tracking). Here's how to track it:
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
const result = await query({
|
||||
prompt: "Your task here",
|
||||
options: {
|
||||
onMessage: (message) => {
|
||||
if (message.type === 'assistant' && message.usage) {
|
||||
console.log(`Message ID: ${message.id}`);
|
||||
console.log(`Usage:`, message.usage);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
[(1)](https://docs.claude.com/en/api/agent-sdk/cost-tracking)
|
||||
|
||||
The final `result` message contains the total cumulative usage from all steps in the conversation[(1)](https://docs.claude.com/en/api/agent-sdk/cost-tracking):
|
||||
|
||||
```typescript
|
||||
console.log("Total usage:", result.usage);
|
||||
console.log("Total cost:", result.usage.total_cost_usd);
|
||||
```
|
||||
[(1)](https://docs.claude.com/en/api/agent-sdk/cost-tracking)
|
||||
|
||||
## Important: Avoid Double-Counting
|
||||
|
||||
When Claude executes tools in parallel, multiple assistant messages may share the same ID and usage data[(1)](https://docs.claude.com/en/api/agent-sdk/cost-tracking). You should only charge once per unique message ID[(1)](https://docs.claude.com/en/api/agent-sdk/cost-tracking):
|
||||
|
||||
```typescript
|
||||
const processedMessageIds = new Set<string>();
|
||||
|
||||
onMessage: (message) => {
|
||||
if (message.type === 'assistant' && message.usage) {
|
||||
// Skip if already processed
|
||||
if (processedMessageIds.has(message.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
processedMessageIds.add(message.id);
|
||||
// Record usage here
|
||||
}
|
||||
}
|
||||
```
|
||||
[(1)](https://docs.claude.com/en/api/agent-sdk/cost-tracking)
|
||||
|
||||
## Usage Fields
|
||||
|
||||
Each usage object contains[(1)](https://docs.claude.com/en/api/agent-sdk/cost-tracking):
|
||||
- `input_tokens`: Base input tokens processed
|
||||
- `output_tokens`: Tokens generated in the response
|
||||
- `cache_creation_input_tokens`: Tokens used to create cache entries
|
||||
- `cache_read_input_tokens`: Tokens read from cache
|
||||
- `total_cost_usd`: Total cost in USD (only in result message)
|
||||
+3
-1
@@ -38,7 +38,9 @@
|
||||
"worker:start": "pm2 start ecosystem.config.cjs",
|
||||
"worker:stop": "pm2 stop claude-mem-worker",
|
||||
"worker:restart": "pm2 restart claude-mem-worker",
|
||||
"worker:logs": "pm2 logs claude-mem-worker"
|
||||
"worker:logs": "pm2 logs claude-mem-worker",
|
||||
"usage:analyze": "node scripts/analyze-usage.js",
|
||||
"usage:today": "node scripts/analyze-usage.js $(date +%Y-%m-%d)"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.27",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|clear|compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
|
||||
File diff suppressed because one or more lines are too long
Executable
+134
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Analyze usage logs from ~/.claude-mem/usage-logs/
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/analyze-usage.js [date]
|
||||
*
|
||||
* Example:
|
||||
* node scripts/analyze-usage.js 2025-11-03
|
||||
* node scripts/analyze-usage.js # Uses today's date
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
const usageDir = join(homedir(), '.claude-mem', 'usage-logs');
|
||||
|
||||
// Get date from command line or use today
|
||||
const targetDate = process.argv[2] || new Date().toISOString().split('T')[0];
|
||||
const filename = `usage-${targetDate}.jsonl`;
|
||||
const filepath = join(usageDir, filename);
|
||||
|
||||
console.log(`\n📊 Usage Analysis for ${targetDate}\n`);
|
||||
console.log(`Reading from: ${filepath}\n`);
|
||||
|
||||
try {
|
||||
const content = readFileSync(filepath, 'utf-8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
let totalCost = 0;
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
let totalCacheCreation = 0;
|
||||
let totalCacheRead = 0;
|
||||
const projectStats = {};
|
||||
const modelStats = {};
|
||||
|
||||
lines.forEach(line => {
|
||||
if (!line.trim()) return;
|
||||
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
|
||||
// Aggregate totals
|
||||
totalCost += entry.totalCostUsd || 0;
|
||||
totalInputTokens += entry.usage?.inputTokens || 0;
|
||||
totalOutputTokens += entry.usage?.outputTokens || 0;
|
||||
totalCacheCreation += entry.usage?.cacheCreationInputTokens || 0;
|
||||
totalCacheRead += entry.usage?.cacheReadInputTokens || 0;
|
||||
|
||||
// Project stats
|
||||
if (!projectStats[entry.project]) {
|
||||
projectStats[entry.project] = {
|
||||
cost: 0,
|
||||
sessions: new Set(),
|
||||
tokens: 0
|
||||
};
|
||||
}
|
||||
projectStats[entry.project].cost += entry.totalCostUsd || 0;
|
||||
projectStats[entry.project].sessions.add(entry.sessionDbId);
|
||||
projectStats[entry.project].tokens += (entry.usage?.inputTokens || 0) + (entry.usage?.outputTokens || 0);
|
||||
|
||||
// Model stats
|
||||
if (!modelStats[entry.model]) {
|
||||
modelStats[entry.model] = {
|
||||
cost: 0,
|
||||
calls: 0,
|
||||
tokens: 0
|
||||
};
|
||||
}
|
||||
modelStats[entry.model].cost += entry.totalCostUsd || 0;
|
||||
modelStats[entry.model].calls += 1;
|
||||
modelStats[entry.model].tokens += (entry.usage?.inputTokens || 0) + (entry.usage?.outputTokens || 0);
|
||||
|
||||
} catch (e) {
|
||||
console.error(`Error parsing line: ${e.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Print summary
|
||||
console.log('═══════════════════════════════════════════════════════════\n');
|
||||
console.log(`📈 Total Cost: $${totalCost.toFixed(4)}`);
|
||||
console.log(`📊 Total API Calls: ${lines.length}`);
|
||||
console.log(`\n🎯 Token Usage:`);
|
||||
console.log(` Input Tokens: ${totalInputTokens.toLocaleString()}`);
|
||||
console.log(` Output Tokens: ${totalOutputTokens.toLocaleString()}`);
|
||||
console.log(` Cache Creation Tokens: ${totalCacheCreation.toLocaleString()}`);
|
||||
console.log(` Cache Read Tokens: ${totalCacheRead.toLocaleString()}`);
|
||||
console.log(` Total Tokens: ${(totalInputTokens + totalOutputTokens).toLocaleString()}`);
|
||||
|
||||
if (totalCacheRead > 0) {
|
||||
const savings = ((totalCacheRead / (totalInputTokens + totalCacheRead)) * 100).toFixed(1);
|
||||
console.log(` Cache Hit Rate: ${savings}%`);
|
||||
}
|
||||
|
||||
console.log(`\n📁 By Project:`);
|
||||
Object.entries(projectStats)
|
||||
.sort((a, b) => b[1].cost - a[1].cost)
|
||||
.forEach(([project, stats]) => {
|
||||
console.log(` ${project}:`);
|
||||
console.log(` Cost: $${stats.cost.toFixed(4)}`);
|
||||
console.log(` Sessions: ${stats.sessions.size}`);
|
||||
console.log(` Tokens: ${stats.tokens.toLocaleString()}`);
|
||||
});
|
||||
|
||||
console.log(`\n🤖 By Model:`);
|
||||
Object.entries(modelStats)
|
||||
.sort((a, b) => b[1].cost - a[1].cost)
|
||||
.forEach(([model, stats]) => {
|
||||
console.log(` ${model}:`);
|
||||
console.log(` Cost: $${stats.cost.toFixed(4)}`);
|
||||
console.log(` Calls: ${stats.calls}`);
|
||||
console.log(` Tokens: ${stats.tokens.toLocaleString()}`);
|
||||
console.log(` Avg Cost/Call: $${(stats.cost / stats.calls).toFixed(4)}`);
|
||||
});
|
||||
|
||||
console.log('\n═══════════════════════════════════════════════════════════\n');
|
||||
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
console.error(`❌ No usage log found for ${targetDate}`);
|
||||
console.log(`\nAvailable logs:`);
|
||||
try {
|
||||
const files = readdirSync(usageDir).filter(f => f.endsWith('.jsonl'));
|
||||
files.forEach(f => console.log(` - ${f}`));
|
||||
} catch (e) {
|
||||
console.error(` Could not read usage logs directory`);
|
||||
}
|
||||
} else {
|
||||
console.error(`❌ Error: ${error.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type { SDKSession } from '../sdk/prompts.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { ensureAllDataDirs } from '../shared/paths.js';
|
||||
import { execSync } from 'child_process';
|
||||
import { UsageLogger } from '../utils/usage-logger.js';
|
||||
|
||||
const MODEL = process.env.CLAUDE_MEM_MODEL || 'claude-sonnet-4-5';
|
||||
const DISALLOWED_TOOLS = ['Glob', 'Grep', 'ListMcpResourcesTool', 'WebSearch'];
|
||||
@@ -83,10 +84,12 @@ class WorkerService {
|
||||
private app: express.Application;
|
||||
private port: number | null = null;
|
||||
private sessions: Map<number, ActiveSession> = new Map();
|
||||
private usageLogger: UsageLogger;
|
||||
|
||||
constructor() {
|
||||
this.app = express();
|
||||
this.app.use(express.json({ limit: '50mb' }));
|
||||
this.usageLogger = new UsageLogger();
|
||||
|
||||
// Health check
|
||||
this.app.get('/health', this.handleHealth.bind(this));
|
||||
@@ -409,6 +412,41 @@ class WorkerService {
|
||||
// Parse and store with prompt number
|
||||
this.handleAgentMessage(session, textContent, session.lastPromptNumber);
|
||||
}
|
||||
|
||||
// Capture usage data from result messages
|
||||
if (message.type === 'result' && message.subtype === 'success') {
|
||||
const usageData = {
|
||||
timestamp: new Date().toISOString(),
|
||||
sessionDbId: session.sessionDbId,
|
||||
claudeSessionId: session.claudeSessionId,
|
||||
project: session.project,
|
||||
promptNumber: session.lastPromptNumber,
|
||||
model: MODEL,
|
||||
sessionId: message.session_id,
|
||||
uuid: message.uuid,
|
||||
durationMs: message.duration_ms,
|
||||
durationApiMs: message.duration_api_ms,
|
||||
numTurns: message.num_turns,
|
||||
totalCostUsd: message.total_cost_usd,
|
||||
usage: {
|
||||
inputTokens: message.usage.input_tokens,
|
||||
outputTokens: message.usage.output_tokens,
|
||||
cacheCreationInputTokens: message.usage.cache_creation_input_tokens,
|
||||
cacheReadInputTokens: message.usage.cache_read_input_tokens
|
||||
}
|
||||
};
|
||||
|
||||
this.usageLogger.logUsage(usageData);
|
||||
|
||||
logger.info('SDK', 'Usage data logged', {
|
||||
sessionId: session.sessionDbId,
|
||||
inputTokens: message.usage.input_tokens,
|
||||
outputTokens: message.usage.output_tokens,
|
||||
cacheCreation: message.usage.cache_creation_input_tokens,
|
||||
cacheRead: message.usage.cache_read_input_tokens,
|
||||
totalCostUsd: message.total_cost_usd
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mark completed
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { appendFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
/**
|
||||
* Usage data structure from Claude Agent SDK result messages
|
||||
*/
|
||||
export interface UsageData {
|
||||
timestamp: string;
|
||||
sessionDbId: number;
|
||||
claudeSessionId: string;
|
||||
project: string;
|
||||
promptNumber: number;
|
||||
model: string;
|
||||
sessionId: string; // SDK session ID
|
||||
uuid: string; // SDK message UUID
|
||||
durationMs: number;
|
||||
durationApiMs: number;
|
||||
numTurns: number;
|
||||
totalCostUsd: number;
|
||||
usage: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheCreationInputTokens: number;
|
||||
cacheReadInputTokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger for capturing usage metrics to JSONL files
|
||||
*/
|
||||
export class UsageLogger {
|
||||
private logDir: string;
|
||||
private logFile: string;
|
||||
|
||||
constructor() {
|
||||
this.logDir = join(homedir(), '.claude-mem', 'usage-logs');
|
||||
// Create a daily log file
|
||||
const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
this.logFile = join(this.logDir, `usage-${date}.jsonl`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log usage data from SDK result message
|
||||
*/
|
||||
logUsage(data: UsageData): void {
|
||||
try {
|
||||
const line = JSON.stringify(data) + '\n';
|
||||
appendFileSync(this.logFile, line, 'utf-8');
|
||||
} catch (error) {
|
||||
console.error('Failed to log usage data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current log file path
|
||||
*/
|
||||
getLogFilePath(): string {
|
||||
return this.logFile;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user