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:
Alex Newman
2025-11-02 21:53:42 -05:00
parent 9215c7e1f5
commit f20bb5bced
7 changed files with 327 additions and 29 deletions
@@ -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
View File
@@ -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",
+1
View File
@@ -3,6 +3,7 @@
"hooks": {
"SessionStart": [
{
"matcher": "startup|clear|compact",
"hooks": [
{
"type": "command",
File diff suppressed because one or more lines are too long
+134
View File
@@ -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);
}
+38
View File
@@ -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
+61
View File
@@ -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;
}
}