From 30451d14cd30772171e83e5da4c8385c666abfcb Mon Sep 17 00:00:00 2001 From: 12122J Date: Tue, 26 May 2026 19:23:15 +0200 Subject: [PATCH 1/2] v0.5.0: remove duration_ms and files tracking duration_ms was always null for hook-recorded sessions (the majority), making the column useless. files tracking (Read/Write/Edit tool calls) was not surfaced in the dashboard and produced unreliable data. Co-Authored-By: Claude Sonnet 4.6 --- dashboard/src/components/SessionDetail.jsx | 13 ------------- dashboard/src/components/SessionsTable.jsx | 11 ----------- package.json | 2 +- src/adapters/transcript.mjs | 4 ---- src/hook-recorder.mjs | 5 ----- src/run-recorder.mjs | 15 --------------- 6 files changed, 1 insertion(+), 49 deletions(-) diff --git a/dashboard/src/components/SessionDetail.jsx b/dashboard/src/components/SessionDetail.jsx index d89e70a..64f21b4 100644 --- a/dashboard/src/components/SessionDetail.jsx +++ b/dashboard/src/components/SessionDetail.jsx @@ -11,15 +11,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 '—'; @@ -283,10 +274,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 || '—'}
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/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..ea91d26 100644 --- a/src/adapters/transcript.mjs +++ b/src/adapters/transcript.mjs @@ -15,7 +15,6 @@ 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 humanParts = []; for (const raw of lines) { @@ -75,8 +74,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 +103,6 @@ export function extractFromTranscript(lines) { total_tokens: total } : null, tools, - files, humanTranscript: humanParts.join('\n\n') }; } diff --git a/src/hook-recorder.mjs b/src/hook-recorder.mjs index e3bce07..407aacd 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,6 @@ export async function recordFromHook({ sessionId, transcriptPath, fallbackCwd }) } : null, session: null, tools: extracted.tools, - files: extracted.files, diff: { files_changed: countPatchFiles(patch) }, artifacts: { events: 'events.jsonl', @@ -81,9 +79,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) { From 45b0aa2fb32443077b463cbd9d95a795d18cd12a Mon Sep 17 00:00:00 2001 From: 12122J Date: Tue, 26 May 2026 19:33:01 +0200 Subject: [PATCH 2/2] =?UTF-8?q?v0.5.0:=20analytics=20charts=20=E2=80=94=20?= =?UTF-8?q?turns,=20top=20cost,=20cache=20efficiency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TurnChart: per-turn stacked bar (input/cache write/output) shown as a new Turns tab in session detail; requires sessions recorded after v0.5 - TopCostChart: horizontal bar ranking top 10 sessions by cost, click to select - CacheEfficiencyChart: daily line chart of cache hit rate (cache reads / all input) - MetricsStrip: 5th card showing overall cache hit rate % - transcript.mjs: extract per-turn usage into a turns[] array stored in run.json - Context bloat warning in session detail when cache writes >> output tokens Co-Authored-By: Claude Sonnet 4.6 --- dashboard/src/App.jsx | 14 ++- .../src/components/CacheEfficiencyChart.jsx | 106 +++++++++++++++++ dashboard/src/components/MetricsStrip.jsx | 21 ++++ dashboard/src/components/SessionDetail.jsx | 23 ++++ dashboard/src/components/TopCostChart.jsx | 109 ++++++++++++++++++ dashboard/src/components/TurnChart.jsx | 88 ++++++++++++++ dashboard/src/styles.css | 17 ++- src/adapters/transcript.mjs | 8 ++ src/hook-recorder.mjs | 1 + 9 files changed, 385 insertions(+), 2 deletions(-) create mode 100644 dashboard/src/components/CacheEfficiencyChart.jsx create mode 100644 dashboard/src/components/TopCostChart.jsx create mode 100644 dashboard/src/components/TurnChart.jsx 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 64f21b4..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 '—'; @@ -51,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; } @@ -193,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); @@ -366,6 +377,14 @@ export default function SessionDetail({ session, vatRate = 0, pricingDb = null, > Diff + {hasTurns && ( + + )}
{activeTab === 'transcript' && ( @@ -379,6 +398,10 @@ export default function SessionDetail({ session, vatRate = 0, pricingDb = null, ?
Loading...
: )} + + {activeTab === 'turns' && ( + + )} ); } 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/src/adapters/transcript.mjs b/src/adapters/transcript.mjs index ea91d26..fcca2e7 100644 --- a/src/adapters/transcript.mjs +++ b/src/adapters/transcript.mjs @@ -15,6 +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 turns = []; const humanParts = []; for (const raw of lines) { @@ -56,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 ?? []) { @@ -103,6 +110,7 @@ export function extractFromTranscript(lines) { total_tokens: total } : null, tools, + turns, humanTranscript: humanParts.join('\n\n') }; } diff --git a/src/hook-recorder.mjs b/src/hook-recorder.mjs index 407aacd..06ff4f9 100644 --- a/src/hook-recorder.mjs +++ b/src/hook-recorder.mjs @@ -61,6 +61,7 @@ export async function recordFromHook({ sessionId, transcriptPath, fallbackCwd }) } : null, session: null, tools: extracted.tools, + turns: extracted.turns, diff: { files_changed: countPatchFiles(patch) }, artifacts: { events: 'events.jsonl',