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) {
|