diff --git a/dashboard/src/App.jsx b/dashboard/src/App.jsx index f0b6a83..42c9829 100644 --- a/dashboard/src/App.jsx +++ b/dashboard/src/App.jsx @@ -1,6 +1,8 @@ import React, { useState, useEffect, useMemo } from 'react'; import MetricsStrip from './components/MetricsStrip.jsx'; import CostChart from './components/CostChart.jsx'; +import CacheEfficiencyChart from './components/CacheEfficiencyChart.jsx'; +import TopCostChart from './components/TopCostChart.jsx'; import SessionsTable from './components/SessionsTable.jsx'; import SessionDetail from './components/SessionDetail.jsx'; import SettingsPanel from './components/SettingsPanel.jsx'; @@ -100,7 +102,17 @@ export default function App() {
- +
+ + +
+
Sessions
a.localeCompare(b)) + .map(([date, v]) => { + const total = v.cacheRead + v.freshInput; + return { + date, + label: formatLabel(date), + rate: total > 0 ? parseFloat(((v.cacheRead / total) * 100).toFixed(1)) : 0, + }; + }); +} + +function formatLabel(dateStr) { + const [, month, day] = dateStr.split('-'); + return `${parseInt(month, 10)}/${parseInt(day, 10)}`; +} + +function CustomTooltip({ active, payload, label }) { + if (!active || !payload?.length) return null; + return ( +
+
{label}
+
{payload[0].value}% cached
+
+ ); +} + +export default function CacheEfficiencyChart({ sessions }) { + const data = useMemo(() => aggregateByDay(sessions), [sessions]); + const hasData = data.some(d => d.rate > 0); + + if (!hasData || data.length === 0) { + return ( +
+
Cache Hit Rate
+
+ No cache data available +
+
+ ); + } + + return ( +
+
Cache Hit Rate
+ + + + + `${v}%`} + width={40} + domain={[0, 100]} + /> + } /> + + + + +
+ ); +} diff --git a/dashboard/src/components/MetricsStrip.jsx b/dashboard/src/components/MetricsStrip.jsx index ebf4323..b23a01b 100644 --- a/dashboard/src/components/MetricsStrip.jsx +++ b/dashboard/src/components/MetricsStrip.jsx @@ -17,6 +17,19 @@ function formatTokens(value) { return String(value); } +function cacheHitRate(sessions) { + let totalCacheRead = 0; + let totalFresh = 0; + for (const s of sessions) { + const u = s.usage; + if (!u) continue; + totalCacheRead += u.cache_read_tokens ?? u.cache_read_input_tokens ?? u.cached_input_tokens ?? 0; + totalFresh += u.input_tokens ?? 0; + } + const total = totalCacheRead + totalFresh; + return total > 0 ? (totalCacheRead / total) * 100 : null; +} + export default function MetricsStrip({ sessions, vatRate = 0, pricingDb = null }) { const costResults = sessions.map(s => sessionCost(s, pricingDb)); const costs = costResults.map(c => c.value).filter(c => c != null); @@ -26,6 +39,7 @@ export default function MetricsStrip({ sessions, vatRate = 0, pricingDb = null } const sessionCount = sessions.length; const avgCost = costs.length > 0 ? totalCost / costs.length : null; const isEstimated = costResults.some(c => c.value != null && c.estimated); + const hitRate = cacheHitRate(sessions); return (
@@ -51,6 +65,13 @@ export default function MetricsStrip({ sessions, vatRate = 0, pricingDb = null }
per run
+
+
Cache Hit Rate
+
+ {hitRate != null ? `${hitRate.toFixed(0)}%` : '—'} +
+
input tokens served from cache
+
); } diff --git a/dashboard/src/components/SessionDetail.jsx b/dashboard/src/components/SessionDetail.jsx index d89e70a..eae8ab5 100644 --- a/dashboard/src/components/SessionDetail.jsx +++ b/dashboard/src/components/SessionDetail.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { lookupPrice, sessionCost } from '../pricing.js'; +import TurnChart from './TurnChart.jsx'; function formatDate(isoString) { if (!isoString) return '—'; @@ -11,15 +12,6 @@ function formatDate(isoString) { }); } -function formatDuration(ms) { - if (ms == null || isNaN(ms)) return '—'; - if (ms < 1000) return `${ms}ms`; - if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; - const m = Math.floor(ms / 60_000); - const s = Math.round((ms % 60_000) / 1000); - return `${m}m ${s}s`; -} - function formatCost(value, estimated = false) { if (value == null || isNaN(value)) return '—'; if (value === 0) return '—'; @@ -60,6 +52,15 @@ function buildWarnings(session) { if (session.git?.after?.dirty) { warnings.push('Working tree was dirty after session completed'); } + const u = session.usage; + if (u) { + const cacheWrite = u.cache_creation_tokens ?? 0; + const output = u.output_tokens ?? 0; + if (cacheWrite > 0 && output > 0 && cacheWrite > output * 8) { + const ratio = (cacheWrite / output).toFixed(0); + warnings.push(`Context bloat: ${ratio}× more cache writes than output — large context loaded with little output`); + } + } return warnings; } @@ -202,6 +203,7 @@ function LabelEditor({ sessionId, value, onChange }) { } export default function SessionDetail({ session, vatRate = 0, pricingDb = null, onLabelChange }) { + const hasTurns = Array.isArray(session?.turns) && session.turns.length > 1; const [activeTab, setActiveTab] = useState('transcript'); const [transcript, setTranscript] = useState(null); const [diff, setDiff] = useState(null); @@ -283,10 +285,6 @@ export default function SessionDetail({ session, vatRate = 0, pricingDb = null,
{session.started_at ? 'Started' : 'Completed'}
{formatDate(session.started_at || session.completed_at)}
-
-
Duration
-
{formatDuration(session.duration_ms)}
-
Model
{session.model || '—'}
@@ -379,6 +377,14 @@ export default function SessionDetail({ session, vatRate = 0, pricingDb = null, > Diff + {hasTurns && ( + + )}
{activeTab === 'transcript' && ( @@ -392,6 +398,10 @@ export default function SessionDetail({ session, vatRate = 0, pricingDb = null, ?
Loading...
: )} + + {activeTab === 'turns' && ( + + )} ); } diff --git a/dashboard/src/components/SessionsTable.jsx b/dashboard/src/components/SessionsTable.jsx index b13c31e..95ec8c1 100644 --- a/dashboard/src/components/SessionsTable.jsx +++ b/dashboard/src/components/SessionsTable.jsx @@ -22,21 +22,11 @@ function formatTokens(value) { return String(value); } -function formatDuration(ms) { - if (ms == null || isNaN(ms)) return '—'; - if (ms < 1000) return `${ms}ms`; - if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; - const m = Math.floor(ms / 60_000); - const s = Math.round((ms % 60_000) / 1000); - return `${m}m ${s}s`; -} - const COLUMNS = [ { key: 'started_at', label: 'Date', sortFn: (a, b) => (a.started_at || a.completed_at || '').localeCompare(b.started_at || b.completed_at || '') }, { key: 'model', label: 'Model', sortFn: (a, b) => (a.model || '').localeCompare(b.model || '') }, { key: 'total_tokens', label: 'Tokens', sortFn: (a, b) => (effectiveTokens(a.usage) ?? -1) - (effectiveTokens(b.usage) ?? -1) }, { key: 'files_changed', label: 'Files', sortFn: (a, b) => (a.diff?.files_changed ?? -1) - (b.diff?.files_changed ?? -1) }, - { key: 'duration_ms', label: 'Duration', sortFn: (a, b) => (a.duration_ms ?? -1) - (b.duration_ms ?? -1) }, { key: 'success', label: 'Status', sortFn: (a, b) => Number(b.success) - Number(a.success) }, ]; @@ -127,7 +117,6 @@ export default function SessionsTable({ sessions, selectedId, onSelect, vatRate {formatTokens(effectiveTokens(session.usage))} {(() => { const { value, estimated } = sessionCost(session, pricingDb); const v = value != null ? value * (1 + vatRate / 100) : null; return formatCost(v, estimated); })()} {session.diff?.files_changed ?? '—'} - {formatDuration(session.duration_ms)} {session.success !== false && session.exit_code === 0 ? 'ok' : `exit ${session.exit_code ?? '?'}`} diff --git a/dashboard/src/components/TopCostChart.jsx b/dashboard/src/components/TopCostChart.jsx new file mode 100644 index 0000000..0edfb23 --- /dev/null +++ b/dashboard/src/components/TopCostChart.jsx @@ -0,0 +1,109 @@ +import React, { useMemo } from 'react'; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, + ResponsiveContainer, Cell, +} from 'recharts'; +import { sessionCost } from '../pricing.js'; + +function truncate(str, len) { + if (!str) return '—'; + return str.length > len ? str.slice(0, len - 1) + '…' : str; +} + +function fmtCost(v) { + if (v == null) return '—'; + if (v >= 1) return `$${v.toFixed(2)}`; + return `$${v.toFixed(4)}`; +} + +function CustomTooltip({ active, payload }) { + if (!active || !payload?.length) return null; + const d = payload[0].payload; + return ( +
+
+ {d.fullLabel} +
+
{fmtCost(payload[0].value)}{d.estimated ? ' (est.)' : ''}
+
+ ); +} + +export default function TopCostChart({ sessions, vatRate = 0, pricingDb = null, selectedId, onSelect }) { + const mult = 1 + vatRate / 100; + + const data = useMemo(() => { + const ranked = sessions + .map(s => { + const { value, estimated } = sessionCost(s, pricingDb); + return { s, cost: value != null ? value * mult : null, estimated }; + }) + .filter(d => d.cost != null && d.cost > 0) + .sort((a, b) => b.cost - a.cost) + .slice(0, 10); + + return ranked.reverse().map(({ s, cost, estimated }) => ({ + id: s.id, + label: truncate(s.label || s.description || s.id, 30), + fullLabel: s.label || s.description || s.id, + cost: parseFloat(cost.toFixed(6)), + estimated, + })); + }, [sessions, vatRate, pricingDb]); + + if (data.length === 0) { + return ( +
+
Top Sessions by Cost
+
+ No cost data available +
+
+ ); + } + + const barHeight = 24; + const chartHeight = data.length * barHeight + 32; + + return ( +
+
Top Sessions by Cost
+ + + + v === 0 ? '' : fmtCost(v)} + /> + + } cursor={{ fill: '#f9fafb' }} /> + onSelect?.(d.id === selectedId ? null : d.id)} + style={{ cursor: 'pointer' }} + > + {data.map(d => ( + + ))} + + + +
+ ); +} diff --git a/dashboard/src/components/TurnChart.jsx b/dashboard/src/components/TurnChart.jsx new file mode 100644 index 0000000..2804444 --- /dev/null +++ b/dashboard/src/components/TurnChart.jsx @@ -0,0 +1,88 @@ +import React, { useMemo } from 'react'; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, + ResponsiveContainer, Legend, +} from 'recharts'; + +function fmt(n) { + if (n == null) return '—'; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); +} + +function CustomTooltip({ active, payload, label }) { + if (!active || !payload?.length) return null; + const billed = payload.reduce((s, p) => s + (p.value ?? 0), 0); + return ( +
+
Turn {label}
+ {payload.map(p => ( +
+ {p.name}: {fmt(p.value)} +
+ ))} +
+ Billed: {fmt(billed)} +
+
+ ); +} + +export default function TurnChart({ turns }) { + const data = useMemo(() => { + if (!turns?.length) return []; + return turns.map((t, i) => ({ + turn: i + 1, + Input: t.input, + 'Cache write': t.cacheWrite, + Output: t.output, + cacheRead: t.cacheRead, + })); + }, [turns]); + + if (!data.length) { + return ( +
+ No per-turn data — requires a session recorded after v0.5. +
+ ); + } + + const maxTicks = Math.min(data.length, 20); + const tickInterval = Math.ceil(data.length / maxTicks) - 1; + + return ( +
+ + + + + v >= 1000 ? `${(v / 1000).toFixed(0)}K` : v} + width={40} + /> + } cursor={{ fill: '#f3f4f6' }} /> + + + + + + + {data.some(d => d.cacheRead > 0) && ( +
+ Cache reads (avg {fmt(Math.round(data.reduce((s, d) => s + d.cacheRead, 0) / data.length))} / turn) not shown — repeated context, not billed as new tokens. +
+ )} +
+ ); +} diff --git a/dashboard/src/styles.css b/dashboard/src/styles.css index 9021462..0efeb9e 100644 --- a/dashboard/src/styles.css +++ b/dashboard/src/styles.css @@ -139,7 +139,7 @@ h1, h2, h3, h4 { .metrics-strip { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(5, 1fr); border-bottom: 1px solid #e5e7eb; } @@ -211,6 +211,21 @@ h1, h2, h3, h4 { /* ── Chart section ── */ +.chart-row { + display: grid; + grid-template-columns: 1fr 1fr; + border-bottom: 1px solid #e5e7eb; +} + +.chart-row .chart-section { + border-bottom: none; + border-right: 1px solid #e5e7eb; +} + +.chart-row .chart-section:last-child { + border-right: none; +} + .chart-section { padding: 24px 24px 16px; border-bottom: 1px solid #e5e7eb; diff --git a/package.json b/package.json index 54ab5d0..02504d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@j___avi/tokentrace", - "version": "0.4.0", + "version": "0.5.0", "description": "Token tracing and session recording for coding agents.", "type": "module", "bin": { diff --git a/src/adapters/transcript.mjs b/src/adapters/transcript.mjs index f8c430a..fcca2e7 100644 --- a/src/adapters/transcript.mjs +++ b/src/adapters/transcript.mjs @@ -15,7 +15,7 @@ export function extractFromTranscript(lines) { const rawUsage = { input_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, output_tokens: 0 }; let hasUsage = false; const tools = { command_count: 0, commands: [] }; - const files = { read_count: 0, reads: [] }; + const turns = []; const humanParts = []; for (const raw of lines) { @@ -57,6 +57,12 @@ export function extractFromTranscript(lines) { rawUsage.cache_creation_input_tokens += u.cache_creation_input_tokens ?? 0; rawUsage.cache_read_input_tokens += u.cache_read_input_tokens ?? 0; rawUsage.output_tokens += u.output_tokens ?? 0; + turns.push({ + input: u.input_tokens ?? 0, + cacheWrite: u.cache_creation_input_tokens ?? 0, + cacheRead: u.cache_read_input_tokens ?? 0, + output: u.output_tokens ?? 0, + }); } for (const block of msg.content ?? []) { @@ -75,8 +81,6 @@ export function extractFromTranscript(lines) { humanParts.push(`[bash] ${command}`); } else if (name === 'Read' || name === 'Write' || name === 'Edit') { const path = block.input?.file_path ?? block.input?.path ?? ''; - files.read_count++; - files.reads.push({ path, bytes: null }); humanParts.push(`[${name.toLowerCase()}] ${path}`); } } @@ -106,7 +110,7 @@ export function extractFromTranscript(lines) { total_tokens: total } : null, tools, - files, + turns, humanTranscript: humanParts.join('\n\n') }; } diff --git a/src/hook-recorder.mjs b/src/hook-recorder.mjs index e3bce07..06ff4f9 100644 --- a/src/hook-recorder.mjs +++ b/src/hook-recorder.mjs @@ -48,7 +48,6 @@ export async function recordFromHook({ sessionId, transcriptPath, fallbackCwd }) entrypoint: extracted.entrypoint, started_at: null, completed_at: completedAt, - duration_ms: null, exit_code: 0, success: true, source: 'hook', @@ -62,7 +61,7 @@ export async function recordFromHook({ sessionId, transcriptPath, fallbackCwd }) } : null, session: null, tools: extracted.tools, - files: extracted.files, + turns: extracted.turns, diff: { files_changed: countPatchFiles(patch) }, artifacts: { events: 'events.jsonl', @@ -81,9 +80,6 @@ export async function recordFromHook({ sessionId, transcriptPath, fallbackCwd }) for (const cmd of extracted.tools.commands) { await writer.write('tool.command', cmd); } - for (const file of extracted.files.reads) { - await writer.write('file.read', file); - } await writer.write('run.completed', { source: 'hook' }); await writer.close(); diff --git a/src/run-recorder.mjs b/src/run-recorder.mjs index 30b1b00..802b460 100644 --- a/src/run-recorder.mjs +++ b/src/run-recorder.mjs @@ -37,7 +37,6 @@ export async function recordRun({ const writer = new EventWriter(eventsPath); const startedAt = nowIso(); - const startedMs = Date.now(); const gitBefore = await getGitSnapshot(resolvedCwd); const observations = createObservationState(); @@ -79,7 +78,6 @@ export async function recordRun({ model, started_at: startedAt, completed_at: completedAt, - duration_ms: Date.now() - startedMs, exit_code: exitCode, success: exitCode === 0, git: { @@ -89,7 +87,6 @@ export async function recordRun({ usage, session: observations.session ?? null, tools: observations.tools, - files: observations.files, diff: { files_changed: countPatchFiles(patch) }, @@ -106,7 +103,6 @@ export async function recordRun({ await writer.write('run.completed', { exit_code: exitCode, success: exitCode === 0, - duration_ms: run.duration_ms }); await writer.close(); @@ -219,10 +215,6 @@ function createObservationState() { command_count: 0, commands: [] }, - files: { - read_count: 0, - reads: [] - } }; } @@ -272,13 +264,6 @@ function applyObservation(state, observation) { }); } - if (observation.type === 'file.read') { - state.files.read_count += 1; - state.files.reads.push({ - path: observation.path, - bytes: observation.bytes - }); - } } async function inferRunModel(command, agent) {