Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 77 additions & 8 deletions src/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -1300,6 +1300,8 @@ function getSessionReplay(sessionId, project) {
};
}

const CONTEXT_WINDOW = 200_000; // Claude's max context window (tokens)

// ── Pricing per model (per token, April 2026) ─────────────

const MODEL_PRICING = {
Expand Down Expand Up @@ -1329,11 +1331,15 @@ function getModelPricing(model) {

function computeSessionCost(sessionId, project) {
const found = findSessionFile(sessionId, project);
if (!found) return { cost: 0, inputTokens: 0, outputTokens: 0, model: '' };
if (!found) return { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: '' };

let totalCost = 0;
let totalInput = 0;
let totalOutput = 0;
let totalCacheRead = 0;
let totalCacheCreate = 0;
let contextPctSum = 0;
let contextTurnCount = 0;
let model = '';

try {
Expand All @@ -1353,12 +1359,21 @@ function computeSessionCost(sessionId, project) {
const cacheRead = u.cache_read_input_tokens || 0;
const out = u.output_tokens || 0;

totalInput += inp + cacheCreate + cacheRead;
totalInput += inp;
totalOutput += out;
totalCacheRead += cacheRead;
totalCacheCreate += cacheCreate;
totalCost += inp * pricing.input
+ cacheCreate * pricing.cache_create
+ cacheRead * pricing.cache_read
+ out * pricing.output;

// Track per-turn context window usage (average, not peak)
const contextThisTurn = inp + cacheCreate + cacheRead;
if (contextThisTurn > 0) {
contextPctSum += (contextThisTurn / CONTEXT_WINDOW) * 100;
contextTurnCount++;
}
}
// Codex: estimate from file size (no token usage in session files)
} catch {}
Expand All @@ -1377,7 +1392,7 @@ function computeSessionCost(sessionId, project) {
} catch {}
}

return { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, model };
return { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, cacheReadTokens: totalCacheRead, cacheCreateTokens: totalCacheCreate, contextPctSum, contextTurnCount, model };
}

// ── Cost analytics ────────────────────────────────────────
Expand All @@ -1386,20 +1401,59 @@ function getCostAnalytics(sessions) {
const byDay = {};
const byProject = {};
const byWeek = {};
const byAgent = {};
let totalCost = 0;
let totalTokens = 0;
let totalInputTokens = 0;
let totalOutputTokens = 0;
let totalCacheReadTokens = 0;
let totalCacheCreateTokens = 0;
let globalContextPctSum = 0;
let globalContextTurnCount = 0;
let firstDate = null;
let lastDate = null;
let sessionsWithData = 0;
const agentNoCostData = {};
for (const s of sessions) {
if (!byAgent[s.tool]) byAgent[s.tool] = { cost: 0, sessions: 0, tokens: 0, estimated: false };
}
const sessionCosts = [];

for (const s of sessions) {
const costData = computeSessionCost(s.id, s.project);
const cost = costData.cost;
const tokens = costData.inputTokens + costData.outputTokens;
if (cost === 0 && tokens === 0) continue;
const tokens = costData.inputTokens + costData.outputTokens + costData.cacheReadTokens + costData.cacheCreateTokens;
if (cost === 0 && tokens === 0) {
if (!agentNoCostData[s.tool]) agentNoCostData[s.tool] = 0;
agentNoCostData[s.tool]++;
continue;
}
sessionsWithData++;
totalCost += cost;
totalTokens += tokens;

// By day
totalInputTokens += costData.inputTokens;
totalOutputTokens += costData.outputTokens;
totalCacheReadTokens += costData.cacheReadTokens;
totalCacheCreateTokens += costData.cacheCreateTokens;

// Per-agent breakdown
const agent = s.tool || 'unknown';
if (!byAgent[agent]) byAgent[agent] = { cost: 0, sessions: 0, tokens: 0, estimated: false };
byAgent[agent].cost += cost;
byAgent[agent].sessions++;
byAgent[agent].tokens += tokens;
if (agent === 'codex') byAgent[agent].estimated = true;

// Context % across all turns
globalContextPctSum += costData.contextPctSum;
globalContextTurnCount += costData.contextTurnCount;

// Date range
const day = s.date || 'unknown';
if (s.date) {
if (!firstDate || s.date < firstDate) firstDate = s.date;
if (!lastDate || s.date > lastDate) lastDate = s.date;
}
if (!byDay[day]) byDay[day] = { cost: 0, sessions: 0, tokens: 0 };
byDay[day].cost += cost;
byDay[day].sessions++;
Expand Down Expand Up @@ -1429,14 +1483,29 @@ function getCostAnalytics(sessions) {
// Sort top sessions by cost
sessionCosts.sort((a, b) => b.cost - a.cost);

const days = firstDate && lastDate
? Math.max(1, Math.round((new Date(lastDate) - new Date(firstDate)) / 86400000) + 1)
: 1;

return {
totalCost,
totalTokens,
totalSessions: sessions.length,
totalInputTokens,
totalOutputTokens,
totalCacheReadTokens,
totalCacheCreateTokens,
avgContextPct: globalContextTurnCount > 0 ? Math.round(globalContextPctSum / globalContextTurnCount) : 0,
dailyRate: totalCost / days,
firstDate,
lastDate,
days,
totalSessions: sessionsWithData,
byDay,
byWeek,
byProject,
topSessions: sessionCosts.slice(0, 10),
byAgent,
agentNoCostData,
};
}

Expand Down
92 changes: 80 additions & 12 deletions src/frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1277,10 +1277,13 @@ async function openDetail(s) {
var row = document.getElementById('detail-real-cost');
if (row) {
row.style.display = '';
var cacheStr = '';
if ((costData.cacheReadTokens || 0) + (costData.cacheCreateTokens || 0) > 0)
cacheStr = ' / ' + formatTokens((costData.cacheReadTokens||0) + (costData.cacheCreateTokens||0)) + ' cache';
row.querySelector('span:last-child').innerHTML =
'<span class="cost-badge" style="background:rgba(74,222,128,0.2);color:var(--accent-green)">$' + costData.cost.toFixed(2) + '</span>' +
' <span style="font-size:11px;color:var(--text-muted)">' +
formatTokens(costData.inputTokens) + ' in / ' + formatTokens(costData.outputTokens) + ' out' +
formatTokens(costData.inputTokens) + ' in / ' + formatTokens(costData.outputTokens) + ' out' + cacheStr +
(costData.model ? ' (' + costData.model + ')' : '') + '</span>';
}
// Update estimated badge to show it was estimated
Expand Down Expand Up @@ -1826,17 +1829,61 @@ async function renderAnalytics(container) {
var html = '<div class="analytics-container">';
html += '<h2 class="heatmap-title">Cost Analytics</h2>';

// Summary cards
// ── Summary cards ──────────────────────────────────────────
html += '<div class="analytics-summary">';
html += '<div class="analytics-card"><span class="analytics-val">~$' + data.totalCost.toFixed(2) + '</span><span class="analytics-label">Total estimated cost</span></div>';
html += '<div class="analytics-card"><span class="analytics-val">$' + data.totalCost.toFixed(2) + '</span><span class="analytics-label">Total cost (API-equivalent)</span></div>';
html += '<div class="analytics-card"><span class="analytics-val">' + formatTokens(data.totalTokens) + '</span><span class="analytics-label">Total tokens</span></div>';
html += '<div class="analytics-card"><span class="analytics-val">$' + (data.dailyRate || 0).toFixed(2) + '</span><span class="analytics-label">Avg per day (' + (data.days || 1) + ' days)</span></div>';
html += '<div class="analytics-card"><span class="analytics-val">' + data.totalSessions + '</span><span class="analytics-label">Sessions</span></div>';
html += '<div class="analytics-card"><span class="analytics-val">~$' + (data.totalCost / Math.max(data.totalSessions, 1)).toFixed(2) + '</span><span class="analytics-label">Avg per session</span></div>';
html += '</div>';

// Cost by day chart (bar chart)
var days = Object.keys(data.byDay).sort();
var last30 = days.slice(-30);
// ── Data coverage note ────────────────────────────────────
if (data.byAgent || data.agentNoCostData) {
var coverageparts = [];
var byAgent = data.byAgent || {};
var noCost = data.agentNoCostData || {};
if (byAgent['claude'] && byAgent['claude'].sessions > 0)
coverageparts.push('<span class="coverage-ok">Claude Code \u2713</span>');
if (byAgent['claude-ext'] && byAgent['claude-ext'].sessions > 0)
coverageparts.push('<span class="coverage-ok">Claude Extension \u2713</span>');
if (byAgent['codex'] && byAgent['codex'].sessions > 0)
coverageparts.push('<span class="coverage-est">Codex ~est.</span>');
if (byAgent['opencode'] && byAgent['opencode'].sessions > 0)
coverageparts.push(byAgent['opencode'].estimated
? '<span class="coverage-est">OpenCode ~est.</span>'
: '<span class="coverage-ok">OpenCode \u2713</span>');
['cursor', 'kiro'].forEach(function(a) {
if (noCost[a] > 0)
coverageparts.push('<span class="coverage-none">' + a + ' \u2717 (no token data)</span>');
});
if (noCost['opencode'] > 0 && !(byAgent['opencode'] && byAgent['opencode'].sessions > 0))
coverageparts.push('<span class="coverage-none">opencode \u2717 (no token data)</span>');
if (coverageparts.length > 0) {
html += '<div class="analytics-coverage">Cost data: ' + coverageparts.join(' \u00b7 ') + '</div>';
}
}

// ── Token breakdown ────────────────────────────────────────
if (data.totalInputTokens !== undefined) {
var totalTok = data.totalInputTokens + data.totalOutputTokens + data.totalCacheReadTokens + data.totalCacheCreateTokens;
var pctOf = function(n) { return totalTok > 0 ? Math.round(n / totalTok * 100) : 0; };
html += '<div class="chart-section analytics-token-breakdown">';
html += '<h3>Token Breakdown</h3>';
html += '<div class="token-breakdown-grid">';
html += '<div class="token-type-card"><span class="token-type-val">' + formatTokens(data.totalInputTokens) + '</span><span class="token-type-label">Input</span><span class="token-type-pct">' + pctOf(data.totalInputTokens) + '%</span></div>';
html += '<div class="token-type-card"><span class="token-type-val">' + formatTokens(data.totalOutputTokens) + '</span><span class="token-type-label">Output</span><span class="token-type-pct">' + pctOf(data.totalOutputTokens) + '%</span></div>';
html += '<div class="token-type-card token-cache-read"><span class="token-type-val">' + formatTokens(data.totalCacheReadTokens) + '</span><span class="token-type-label">Cache read</span><span class="token-type-pct">' + pctOf(data.totalCacheReadTokens) + '%</span></div>';
html += '<div class="token-type-card token-cache-create"><span class="token-type-val">' + formatTokens(data.totalCacheCreateTokens) + '</span><span class="token-type-label">Cache write</span><span class="token-type-pct">' + pctOf(data.totalCacheCreateTokens) + '%</span></div>';
if (data.avgContextPct > 0) {
html += '<div class="token-type-card token-context"><span class="token-type-val">' + data.avgContextPct + '%</span><span class="token-type-label">Avg context used</span><span class="token-type-pct">of 200K</span></div>';
}
html += '</div>';
html += '</div>';
}

// ── Daily cost chart ───────────────────────────────────────
var dayKeys = Object.keys(data.byDay).sort();
var last30 = dayKeys.slice(-30);
if (last30.length > 0) {
var maxCost = Math.max.apply(null, last30.map(function(d) { return data.byDay[d].cost; }));
html += '<div class="chart-section"><h3>Daily Cost (last 30 days)</h3>';
Expand All @@ -1845,15 +1892,15 @@ async function renderAnalytics(container) {
var c = data.byDay[d];
var pct = maxCost > 0 ? (c.cost / maxCost * 100) : 0;
var label = d.slice(5); // MM-DD
html += '<div class="bar-col" title="' + d + ': ~$' + c.cost.toFixed(2) + ' (' + c.sessions + ' sessions)">';
html += '<div class="bar-col" title="' + d + ': $' + c.cost.toFixed(2) + ' (' + c.sessions + ' sessions)">';
html += '<div class="bar-fill" style="height:' + pct + '%"></div>';
html += '<div class="bar-label">' + label + '</div>';
html += '</div>';
});
html += '</div></div>';
}

// Cost by project (horizontal bars)
// ── Cost by project ────────────────────────────────────────
var projects = Object.entries(data.byProject).sort(function(a, b) { return b[1].cost - a[1].cost; });
var topProjects = projects.slice(0, 10);
if (topProjects.length > 0) {
Expand All @@ -1867,19 +1914,19 @@ async function renderAnalytics(container) {
html += '<div class="hbar-row">';
html += '<span class="hbar-name">' + escHtml(name) + '</span>';
html += '<div class="hbar-track"><div class="hbar-fill" style="width:' + pct + '%"></div></div>';
html += '<span class="hbar-val">~$' + info.cost.toFixed(2) + '</span>';
html += '<span class="hbar-val">$' + info.cost.toFixed(2) + '</span>';
html += '</div>';
});
html += '</div></div>';
}

// Top expensive sessions
// ── Top expensive sessions ─────────────────────────────────
if (data.topSessions && data.topSessions.length > 0) {
html += '<div class="chart-section"><h3>Most Expensive Sessions</h3>';
html += '<div class="top-sessions">';
data.topSessions.forEach(function(s) {
html += '<div class="top-session-row" onclick="onCardClick(\'' + s.id + '\', event)">';
html += '<span class="top-session-cost">~$' + s.cost.toFixed(2) + '</span>';
html += '<span class="top-session-cost">$' + s.cost.toFixed(2) + '</span>';
html += '<span class="top-session-project">' + escHtml(s.project) + '</span>';
html += '<span class="top-session-date">' + (s.date || '') + '</span>';
html += '<span class="top-session-id">' + s.id.slice(0, 8) + '</span>';
Expand All @@ -1888,6 +1935,27 @@ async function renderAnalytics(container) {
html += '</div></div>';
}

// ── Cost by agent ──────────────────────────────────────────
var agentEntries = Object.entries(data.byAgent || {}).filter(function(e) { return e[1].sessions > 0; });
if (agentEntries.length > 1) {
agentEntries.sort(function(a, b) { return b[1].cost - a[1].cost; });
html += '<div class="chart-section"><h3>Cost by Agent</h3>';
html += '<div class="hbar-chart">';
var maxAgentCost = agentEntries[0][1].cost || 1;
agentEntries.forEach(function(entry) {
var name = entry[0]; var info = entry[1];
var pct = maxAgentCost > 0 ? (info.cost / maxAgentCost * 100) : 0;
var label = { 'claude': 'Claude Code', 'claude-ext': 'Claude Ext', 'codex': 'Codex', 'opencode': 'OpenCode', 'cursor': 'Cursor', 'kiro': 'Kiro' }[name] || name;
var estMark = info.estimated ? ' <span style="font-size:10px;opacity:0.6">~est.</span>' : '';
html += '<div class="hbar-row">';
html += '<span class="hbar-name">' + label + estMark + '</span>';
html += '<div class="hbar-track"><div class="hbar-fill" style="width:' + pct + '%"></div></div>';
html += '<span class="hbar-val">$' + info.cost.toFixed(2) + ' <span style="font-size:10px;opacity:0.6">(' + info.sessions + ' sess.)</span></span>';
html += '</div>';
});
html += '</div></div>';
}

html += '</div>';
container.innerHTML = html;
} catch (e) {
Expand Down
43 changes: 43 additions & 0 deletions src/frontend/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2073,6 +2073,49 @@ body {
.top-session-date { color: var(--text-muted); font-size: 12px; }
.top-session-id { font-family: monospace; font-size: 11px; color: var(--text-muted); }

/* ── Data coverage indicators ──────────────────────────────── */

.analytics-coverage {
font-size: 12px;
color: var(--text-muted);
margin: -8px 0 16px;
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}

.coverage-ok { color: var(--accent-green); }
.coverage-est { color: var(--accent-orange, #f59e0b); }
.coverage-none { color: var(--text-muted); opacity: 0.6; }

/* ── Token breakdown grid ─────────────────────────────────── */

.token-breakdown-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}

.token-type-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
text-align: center;
display: flex;
flex-direction: column;
gap: 4px;
}

.token-type-val { font-size: 18px; font-weight: 700; color: var(--text); }
.token-type-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
.token-type-pct { font-size: 12px; color: var(--text-muted); }

.token-cache-read { border-color: rgba(96, 165, 250, 0.3); }
.token-cache-create { border-color: rgba(251, 191, 36, 0.3); }
.token-context { border-color: rgba(168, 85, 247, 0.3); }

/* ── Update banner ──────────────────────────────────────────── */

.update-banner {
Expand Down