Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ If you build and operate your own agent in production, use a tracing platform. I

## Features

- **Per-turn ledger** — In the session summary: one row per user turn with wall-clock time, tokens (input + output + cache) and cost, bars scaled to the session maximum, tool-call and error counts, click to jump. Answers "why did this take 40 minutes / cost $3" without reading the transcript.
- **Multi-platform** — Unified view across OpenClaw, Codex, Claude Code, Hermes, OMP, DeepSeek Harness and Gemini CLI sessions (dsh's multi-frame zstd session logs are decompressed transparently; Gemini CLI's `/rewind` checkpoints are folded so rewound history never renders twice)
- **Session browser** — Browse agents, filter/search sessions, view message history
- **Tool call inspection** — Expandable tool calls with arguments and results
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/legacy-pure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export {
getTextContent,
clusterPrefillContent,
buildTraceTurns,
buildTurnLedger,
pickAutoPlatform,
} from './pure';
export { escapeHtml, renderMarkdownHtml, renderMarkdown } from './markdown';
119 changes: 119 additions & 0 deletions frontend/src/lib/pure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,122 @@ export function buildTraceTurns(msgs: SessionMessage[], agentSpans: AgentSpan[]
for (const tn of turns) tn.spans.sort((a, b) => a.start - b.start);
return turns.filter((tn) => tn.spans.length > 0);
}

// --- Per-turn ledger: where did the time, tokens and dollars go? ---
// A turn = one user message plus everything the agent did until the next user
// message. Usage is summed from assistant messages' `usage` (already normalised
// per platform: input/output/cacheRead/cacheWrite tokens, cost as number or
// {total}). Missing usage on a platform leaves the token/cost columns at zero
// rather than hiding the row: the time column still tells the story.

export interface TurnLedgerRow {
index: number;
text: string;
messageId: string | null;
start: number;
end: number;
durationMs: number;
toolCalls: number;
toolErrors: number;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
cost: number;
}

export interface TurnLedger {
rows: TurnLedgerRow[];
totals: { durationMs: number; toolCalls: number; tokens: number; cost: number };
hasUsage: boolean;
hasCost: boolean;
}

function usageCost(usage: SessionMessage['usage']): number {
const c = usage?.cost;
if (typeof c === 'number') return c;
return typeof c?.total === 'number' ? c.total : 0;
}

function usageNumber(usage: SessionMessage['usage'], ...keys: string[]): number {
if (!usage) return 0;
for (const k of keys) {
const v = usage[k];
if (typeof v === 'number') return v;
}
return 0;
}

export function buildTurnLedger(msgs: SessionMessage[]): TurnLedger {
const rows: TurnLedgerRow[] = [];
let cur: TurnLedgerRow | null = null;
let hasUsage = false;
let hasCost = false;

for (const m of msgs) {
const t = parseTimestampMs(m.timestamp);
if (m.role === 'user') {
const text = getTextContent(m.content || [])
.replace(/\s+/g, ' ')
.trim();
cur = {
index: rows.length + 1,
text: text.slice(0, 140) || '(user)',
messageId: m.id || null,
start: t ?? Number.NaN,
end: t ?? Number.NaN,
durationMs: 0,
toolCalls: 0,
toolErrors: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
cost: 0,
};
rows.push(cur);
continue;
}
if (!cur) continue; // pre-user preamble (system prompts) belongs to no turn
if (t !== null) {
if (Number.isNaN(cur.start)) cur.start = t;
cur.end = Math.max(Number.isNaN(cur.end) ? t : cur.end, t);
}
if (m.role === 'toolCall') cur.toolCalls++;
if (m.role === 'toolResult' && m.isError) cur.toolErrors++;
for (const c of m.content || []) {
if (c.type === 'toolCall' || c.type === 'tool_use') cur.toolCalls++;
if (c.type === 'tool_result' && c.is_error) cur.toolErrors++;
}
if (m.usage) {
const inp = usageNumber(m.usage, 'input', 'inputTokens', 'input_tokens', 'prompt_tokens');
const out = usageNumber(m.usage, 'output', 'outputTokens', 'output_tokens', 'completion_tokens');
const cr = usageNumber(m.usage, 'cacheRead', 'cache_read_input_tokens', 'cacheReadTokens');
const cw = usageNumber(m.usage, 'cacheWrite', 'cache_creation_input_tokens', 'cacheWriteTokens');
const total = usageNumber(m.usage, 'totalTokens', 'total_tokens');
if (inp || out || cr || cw || total) hasUsage = true;
cur.inputTokens += inp || (out || cr || cw ? 0 : total);
cur.outputTokens += out;
cur.cacheReadTokens += cr;
cur.cacheWriteTokens += cw;
const cost = usageCost(m.usage);
if (cost) hasCost = true;
cur.cost += cost;
}
}

for (const r of rows) {
r.durationMs = Number.isNaN(r.start) || Number.isNaN(r.end) ? 0 : Math.max(0, r.end - r.start);
}
const totals = rows.reduce(
(acc, r) => {
acc.durationMs += r.durationMs;
acc.toolCalls += r.toolCalls;
acc.tokens += r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheWriteTokens;
acc.cost += r.cost;
return acc;
},
{ durationMs: 0, toolCalls: 0, tokens: 0, cost: 0 }
);
return { rows, totals, hasUsage, hasCost };
}
2 changes: 2 additions & 0 deletions frontend/src/views/sessions/SessionSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { formatCost, formatDurationCompact } from '@/lib/pure';
import { cn } from '@/lib/utils';
import { dirForPlatform, loadStoredFlag, saveStoredFlag, SUMMARY_COLLAPSED_KEY, useAppStore } from '@/store';
import { ChildAgentsSection } from '@/views/trace/ChildAgentsSection';
import { TurnLedger } from './TurnLedger';
import type { ExportFormat } from './exports';
import { runExport } from './exports';
import type { MsgFilter, TimingAnalysis } from './lib';
Expand Down Expand Up @@ -350,6 +351,7 @@ export function SessionSummary({
</div>
</div>
</div>
<TurnLedger messages={msgs} onScrollToMessage={onScrollToMessage} />
<ChildAgentsSection />
</div>
) : null}
Expand Down
122 changes: 122 additions & 0 deletions frontend/src/views/sessions/TurnLedger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Per-turn ledger: the answer to "why did this session take 40 minutes / cost $3".
// One row per user turn; bars are proportional to the session maximum so the
// expensive turn is visible without reading numbers. Clicking a row jumps to
// the user message that started it.

import { useMemo, useState } from 'react';
import type { SessionMessage } from '@/api/types';
import { buildTurnLedger, formatCost, formatDurationCompact } from '@/lib/pure';
import { cn } from '@/lib/utils';
import { formatNumber } from './lib';

type SortKey = 'index' | 'durationMs' | 'tokens' | 'cost';

function Bar({ value, max, className }: { value: number; max: number; className: string }) {
const pct = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0;
return (
<div className="h-1.5 w-full rounded bg-border/60">
<div className={cn('h-1.5 rounded', className)} style={{ width: `${pct}%` }} />
</div>
);
}

export function TurnLedger({
messages,
onScrollToMessage,
}: {
messages: SessionMessage[];
onScrollToMessage: (id: string) => void;
}) {
const ledger = useMemo(() => buildTurnLedger(messages), [messages]);
const [sort, setSort] = useState<SortKey>('index');

const rows = useMemo(() => {
const withTokens = ledger.rows.map((r) => ({
...r,
tokens: r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheWriteTokens,
}));
if (sort === 'index') return withTokens;
return [...withTokens].sort((a, b) => b[sort] - a[sort]);
}, [ledger, sort]);

if (ledger.rows.length < 2) return null;

const maxMs = Math.max(...rows.map((r) => r.durationMs), 0);
const maxTok = Math.max(...rows.map((r) => r.tokens), 0);
const maxCost = Math.max(...rows.map((r) => r.cost), 0);
const { totals } = ledger;

const header = (key: SortKey, label: string, title: string) => (
<button
type="button"
title={title}
onClick={() => setSort(key)}
className={cn('text-left hover:text-foreground', sort === key ? 'text-foreground' : 'text-muted-foreground')}
>
{label}
{sort === key && key !== 'index' ? ' ▾' : ''}
</button>
);

return (
<div data-testid="turn-ledger">
<div className="mb-1 flex items-baseline justify-between">
<div className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Per-turn ledger
</div>
<div className="text-[10px] text-muted-foreground">
{ledger.rows.length} turns · {formatDurationCompact(totals.durationMs)}
{ledger.hasUsage ? ` · ${formatNumber(totals.tokens)} tok` : ''}
{ledger.hasCost ? ` · ${formatCost(totals.cost)}` : ''}
</div>
</div>
<div
className="grid gap-x-3 gap-y-1 text-[11px]"
style={{
gridTemplateColumns: `2rem minmax(0,1fr) 7rem${ledger.hasUsage ? ' 7rem' : ''}${ledger.hasCost ? ' 6rem' : ''}`,
}}
>
<div>{header('index', '#', 'Session order')}</div>
<div className="text-muted-foreground">turn</div>
<div>{header('durationMs', 'time', 'Wall-clock from the user message to the last agent message of the turn')}</div>
{ledger.hasUsage ? (
<div>{header('tokens', 'tokens', 'input + output + cache read + cache write, summed over the turn')}</div>
) : null}
{ledger.hasCost ? <div>{header('cost', 'cost', 'Reported cost summed over the turn')}</div> : null}

{rows.map((r) => (
<button
type="button"
key={r.index}
onClick={() => r.messageId && onScrollToMessage(r.messageId)}
title={`${r.toolCalls} tool calls${r.toolErrors ? `, ${r.toolErrors} errors` : ''} — click to jump`}
className="contents text-left"
>
<div className="text-muted-foreground tabular-nums">{r.index}</div>
<div className="min-w-0 truncate">
{r.toolErrors > 0 ? <span className="text-destructive">✕ </span> : null}
{r.text}
{r.toolCalls > 0 ? <span className="text-muted-foreground"> · {r.toolCalls} tools</span> : null}
</div>
<div>
<div className="tabular-nums">{formatDurationCompact(r.durationMs)}</div>
<Bar value={r.durationMs} max={maxMs} className="bg-[#e3b341]" />
</div>
{ledger.hasUsage ? (
<div>
<div className="tabular-nums">{formatNumber(r.tokens)}</div>
<Bar value={r.tokens} max={maxTok} className="bg-primary/70" />
</div>
) : null}
{ledger.hasCost ? (
<div>
<div className="tabular-nums">{formatCost(r.cost)}</div>
<Bar value={r.cost} max={maxCost} className="bg-[#3fb950]" />
</div>
) : null}
</button>
))}
</div>
</div>
);
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@alloevil/agent-xray",
"version": "1.16.0",
"version": "1.17.0",
"description": "Web dashboard for viewing AI agent session logs — supports OpenClaw, Codex, Claude Code, Hermes, OMP, DeepSeek Harness, and Gemini CLI",
"main": "server.js",
"bin": {
Expand Down
83 changes: 83 additions & 0 deletions public/js/pure.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ var __axrPure = (() => {
var legacy_pure_exports = {};
__export(legacy_pure_exports, {
buildTraceTurns: () => buildTraceTurns,
buildTurnLedger: () => buildTurnLedger,
clusterPrefillContent: () => clusterPrefillContent,
escapeHtml: () => escapeHtml,
firstInformativeLine: () => firstInformativeLine,
Expand Down Expand Up @@ -191,6 +192,88 @@ var __axrPure = (() => {
for (const tn of turns) tn.spans.sort((a, b) => a.start - b.start);
return turns.filter((tn) => tn.spans.length > 0);
}
function usageCost(usage) {
const c = usage?.cost;
if (typeof c === "number") return c;
return typeof c?.total === "number" ? c.total : 0;
}
function usageNumber(usage, ...keys) {
if (!usage) return 0;
for (const k of keys) {
const v = usage[k];
if (typeof v === "number") return v;
}
return 0;
}
function buildTurnLedger(msgs) {
const rows = [];
let cur = null;
let hasUsage = false;
let hasCost = false;
for (const m of msgs) {
const t = parseTimestampMs(m.timestamp);
if (m.role === "user") {
const text = getTextContent(m.content || []).replace(/\s+/g, " ").trim();
cur = {
index: rows.length + 1,
text: text.slice(0, 140) || "(user)",
messageId: m.id || null,
start: t ?? Number.NaN,
end: t ?? Number.NaN,
durationMs: 0,
toolCalls: 0,
toolErrors: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
cost: 0
};
rows.push(cur);
continue;
}
if (!cur) continue;
if (t !== null) {
if (Number.isNaN(cur.start)) cur.start = t;
cur.end = Math.max(Number.isNaN(cur.end) ? t : cur.end, t);
}
if (m.role === "toolCall") cur.toolCalls++;
if (m.role === "toolResult" && m.isError) cur.toolErrors++;
for (const c of m.content || []) {
if (c.type === "toolCall" || c.type === "tool_use") cur.toolCalls++;
if (c.type === "tool_result" && c.is_error) cur.toolErrors++;
}
if (m.usage) {
const inp = usageNumber(m.usage, "input", "inputTokens", "input_tokens", "prompt_tokens");
const out = usageNumber(m.usage, "output", "outputTokens", "output_tokens", "completion_tokens");
const cr = usageNumber(m.usage, "cacheRead", "cache_read_input_tokens", "cacheReadTokens");
const cw = usageNumber(m.usage, "cacheWrite", "cache_creation_input_tokens", "cacheWriteTokens");
const total = usageNumber(m.usage, "totalTokens", "total_tokens");
if (inp || out || cr || cw || total) hasUsage = true;
cur.inputTokens += inp || (out || cr || cw ? 0 : total);
cur.outputTokens += out;
cur.cacheReadTokens += cr;
cur.cacheWriteTokens += cw;
const cost = usageCost(m.usage);
if (cost) hasCost = true;
cur.cost += cost;
}
}
for (const r of rows) {
r.durationMs = Number.isNaN(r.start) || Number.isNaN(r.end) ? 0 : Math.max(0, r.end - r.start);
}
const totals = rows.reduce(
(acc, r) => {
acc.durationMs += r.durationMs;
acc.toolCalls += r.toolCalls;
acc.tokens += r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheWriteTokens;
acc.cost += r.cost;
return acc;
},
{ durationMs: 0, toolCalls: 0, tokens: 0, cost: 0 }
);
return { rows, totals, hasUsage, hasCost };
}

// frontend/src/lib/markdown.ts
function escapeHtml(value) {
Expand Down
Loading