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
60 changes: 39 additions & 21 deletions src/chrome/src/trace/recorder.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
normalizedThreshold,
TRACE_REPAIR_STALE_AFTER_MS,
} from './repair.js';
import { createTraceStats, addTraceEvent, aggregateTraceRuns } from './stats.js';

/**
* Trace recorder — writes per-run traces (LLM requests/responses, tool calls,
Expand Down Expand Up @@ -331,6 +332,14 @@ export async function startRun(meta = {}) {
stepCount: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
llmRequestCount: 0,
llmResponseCount: 0,
toolCallCount: 0,
visionSubCallCount: 0,
errorCount: 0,
retryCount: 0,
totalLlmLatencyMs: 0,
totalToolLatencyMs: 0,
finalContent: null,
};
await promisifyReq(tx(db, ['runs']).objectStore('runs').put(record));
Expand Down Expand Up @@ -656,7 +665,7 @@ export async function endRun(runId, { status = 'done', finalContent = null } = {
// all llm_response events — providers report this in their native units
// (OpenRouter & OpenAI: USD). Surfaced in the Traces UI so users can
// spot expensive-failure runs at a glance.
let totalIn = 0, totalOut = 0, totalCost = 0, stepCount = 0;
const stats = createTraceStats();
let sawLoopError = false;
await new Promise((resolve) => {
const idx = tx(db, ['events'], 'readonly').objectStore('events').index('runId');
Expand All @@ -666,15 +675,7 @@ export async function endRun(runId, { status = 'done', finalContent = null } = {
if (!c) return resolve();
const ev = c.value;
if (ev.kind === 'error' && ev.data?.phase === 'loop') sawLoopError = true;
if (ev.kind === 'llm_response') {
stepCount = Math.max(stepCount, ev.data?.step || 0);
const u = ev.data?.usage;
if (u) {
totalIn += u.prompt_tokens || 0;
totalOut += u.completion_tokens || 0;
if (typeof u.cost === 'number' && Number.isFinite(u.cost)) totalCost += u.cost;
}
}
addTraceEvent(stats, ev);
c.continue();
};
req.onerror = () => resolve();
Expand All @@ -686,10 +687,18 @@ export async function endRun(runId, { status = 'done', finalContent = null } = {
existing.durationMs = existing.endedAt - existing.startedAt;
existing.status = finalStatus;
existing.finalContent = finalContent;
existing.stepCount = stepCount;
existing.totalInputTokens = totalIn;
existing.totalOutputTokens = totalOut;
existing.totalCost = totalCost; // null/0 when the provider didn't report cost
existing.stepCount = stats.stepCount;
existing.totalInputTokens = stats.totalInputTokens;
existing.totalOutputTokens = stats.totalOutputTokens;
existing.totalCost = stats.totalCost; // null/0 when the provider didn't report cost
existing.llmRequestCount = stats.llmRequestCount;
existing.llmResponseCount = stats.llmResponseCount;
existing.toolCallCount = stats.toolCallCount;
existing.visionSubCallCount = stats.visionSubCallCount;
existing.errorCount = stats.errorCount;
existing.retryCount = stats.retryCount;
existing.totalLlmLatencyMs = stats.totalLlmLatencyMs;
existing.totalToolLatencyMs = stats.totalToolLatencyMs;
await promisifyReq(tx(db, ['runs']).objectStore('runs').put(existing));
}
} catch (e) {
Expand Down Expand Up @@ -760,24 +769,33 @@ export async function repairStaleRuns({

export async function listRuns({ limit = 500, conversationId = null } = {}) {
const db = await openDB();
const idx = tx(db, ['runs'], 'readonly').objectStore('runs').index('startedAt');
const store = tx(db, ['runs'], 'readonly').objectStore('runs');
const sessionQuery = Boolean(conversationId && store.indexNames.contains('sessionId'));
const idx = store.index(sessionQuery ? 'sessionId' : 'startedAt');
const out = [];
// When conversationId is set, only matching runs count toward `limit`, so a
// chat's tool-chain export is not starved by unrelated newer runs.
await new Promise((resolve, reject) => {
const req = idx.openCursor(null, 'prev');
const req = sessionQuery
? idx.openCursor(IDBKeyRange.only(conversationId))
: idx.openCursor(null, 'prev');
req.onsuccess = () => {
const c = req.result;
if (!c || out.length >= limit) return resolve();
if (!c || (!sessionQuery && out.length >= limit)) return resolve();
const row = c.value;
if (!conversationId || row?.conversationId === conversationId) {
out.push(row);
}
if (sessionQuery || !conversationId || row?.conversationId === conversationId) out.push(row);
c.continue();
};
req.onerror = () => reject(req.error || new Error('listRuns failed'));
});
return out;
if (sessionQuery) out.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0));
return out.slice(0, limit);
}

export async function getSessionStats(conversationId, { limit = 500 } = {}) {
if (!conversationId) return aggregateTraceRuns([]);
const runs = await listRuns({ limit, conversationId });
return aggregateTraceRuns(runs);
}

export async function getRun(runId) {
Expand Down
101 changes: 101 additions & 0 deletions src/chrome/src/trace/stats.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Pure trace statistics reducers shared by the recorder, Traces UI, and tests.
*
* Event statistics are computed once when a run is finalized and persisted on
* its run record. Session statistics then sum those bounded run snapshots
* through the existing conversation/session index without replaying events.
*/

function nonNegativeNumber(value) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : 0;
}
function stepNumber(value) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? Math.trunc(number) : 0;
}

export function createTraceStats() {
return {
stepCount: 0,
llmRequestCount: 0,
llmResponseCount: 0,
toolCallCount: 0,
visionSubCallCount: 0,
errorCount: 0,
retryCount: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCost: 0,
totalLlmLatencyMs: 0,
totalToolLatencyMs: 0,
hasLoopError: false,
};
}

export function addTraceEvent(stats, event) {
if (!stats || !event || typeof event !== 'object') return stats;
const data = event.data && typeof event.data === 'object' ? event.data : {};

if (event.kind === 'llm_request') {
stats.llmRequestCount += 1;
} else if (event.kind === 'llm_response') {
stats.llmResponseCount += 1;
stats.stepCount = Math.max(stats.stepCount, stepNumber(data.step));
const usage = data.usage && typeof data.usage === 'object' ? data.usage : {};
stats.totalInputTokens += nonNegativeNumber(usage.prompt_tokens);
stats.totalOutputTokens += nonNegativeNumber(usage.completion_tokens);
stats.totalCost += nonNegativeNumber(usage.cost);
stats.totalLlmLatencyMs += nonNegativeNumber(data.latencyMs);
} else if (event.kind === 'tool') {
stats.toolCallCount += 1;
stats.totalToolLatencyMs += nonNegativeNumber(data.latencyMs);
} else if (event.kind === 'vision_sub_call') {
stats.visionSubCallCount += 1;
} else if (event.kind === 'error') {
stats.errorCount += 1;
if (data.phase === 'loop') stats.hasLoopError = true;
} else if (event.kind === 'note' && data.note === 'llm_retry') {
stats.retryCount += 1;
}

return stats;
}

export function buildTraceStats(events) {
const stats = createTraceStats();
for (const event of Array.isArray(events) ? events : []) addTraceEvent(stats, event);
return stats;
}

export function aggregateTraceRuns(runs) {
const stats = createTraceStats();
let runCount = 0;
let runningRunCount = 0;

for (const run of Array.isArray(runs) ? runs : []) {
if (!run || typeof run !== 'object') continue;
runCount += 1;
if (run.status === 'running') runningRunCount += 1;
if (run.status === 'loop_stopped') stats.hasLoopError = true;
stats.stepCount += nonNegativeNumber(run.stepCount);
stats.llmRequestCount += nonNegativeNumber(run.llmRequestCount);
stats.llmResponseCount += nonNegativeNumber(run.llmResponseCount);
stats.toolCallCount += nonNegativeNumber(run.toolCallCount);
stats.visionSubCallCount += nonNegativeNumber(run.visionSubCallCount);
stats.errorCount += nonNegativeNumber(run.errorCount);
stats.retryCount += nonNegativeNumber(run.retryCount);
stats.totalInputTokens += nonNegativeNumber(run.totalInputTokens);
stats.totalOutputTokens += nonNegativeNumber(run.totalOutputTokens);
stats.totalCost += nonNegativeNumber(run.totalCost);
stats.totalLlmLatencyMs += nonNegativeNumber(run.totalLlmLatencyMs);
stats.totalToolLatencyMs += nonNegativeNumber(run.totalToolLatencyMs);
}

return {
runCount,
runningRunCount,
completedRunCount: runCount - runningRunCount,
...stats,
};
}
5 changes: 5 additions & 0 deletions src/chrome/src/ui/traces.html
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,11 @@
letter-spacing: 0.04em;
margin-bottom: 6px;
}
.conv-summary {
margin-bottom: 7px;
color: var(--text2);
font-size: 11px;
}
.conv-turns {
display: flex;
gap: 6px;
Expand Down
20 changes: 17 additions & 3 deletions src/chrome/src/ui/traces.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@

import {
listRuns, getRun, getRunEvents, getScreenshot,
deleteRun, clearAllRuns, repairStaleRuns,
getSessionStats, deleteRun, clearAllRuns, repairStaleRuns,
} from '../trace/recorder.js';
import { isKnownKind, isIgnorableKind } from '../trace/event-model.js';
import { buildTraceTrajectory } from '../trace/trajectory.js';
import { aggregateTraceRuns } from '../trace/stats.js';
import { sanitizeTraceExport } from '../agent/trace-export.js';
import { t } from './i18n.js';
import { escapeHtml, escapeAttr } from './utils.js';
Expand Down Expand Up @@ -240,10 +241,19 @@ async function renderCompare(aId, bId) {
* chat) so users can jump between them. Hidden in compare mode (panes are
* already two-up) and when there's only one run in the conversation.
*/
function renderConversationPanel(run, compact) {
function renderConversationPanel(run, compact, sessionStats = null) {
if (compact) return '';
const siblings = siblingsOf(run);
if (siblings.length < 2) return '';
const stats = sessionStats || aggregateTraceRuns(siblings);
const totalTokens = stats.totalInputTokens + stats.totalOutputTokens;
const summary = [
t(stats.runCount === 1 ? 'tr.run' : 'tr.runs', { n: stats.runCount }),
t(stats.stepCount === 1 ? 'tr.step' : 'tr.steps_plural', { n: stats.stepCount }),
totalTokens ? t('tr.tokens_short', { n: totalTokens.toLocaleString() }) : '',
formatCost(stats.totalCost) ? `${t('tr.cost.label')}: ${formatCost(stats.totalCost)}` : '',
stats.errorCount ? `${t('tr.event.error_kind')} ×${stats.errorCount}` : '',
].filter(Boolean).join(' · ');
const turnNumber = siblings.findIndex(r => r.runId === run.runId) + 1;
const items = siblings.map((r, i) => {
const isCurrent = r.runId === run.runId;
Expand All @@ -257,6 +267,7 @@ function renderConversationPanel(run, compact) {
return `
<div class="conv-panel">
<div class="conv-panel-label">${escapeHtml(t('tr.conversation.label'))} · ${escapeHtml(t('tr.conversation.turn_of', { n: turnNumber, total: siblings.length }))}</div>
<div class="conv-summary">${escapeHtml(summary)}</div>
<div class="conv-turns">${items}</div>
</div>
`;
Expand Down Expand Up @@ -337,6 +348,9 @@ function renderStepTrajectory(events, compact) {
}

async function buildRunView(run, events, compact, objectUrls = new Set()) {
const sessionStats = !compact && run.conversationId
? await getSessionStats(run.conversationId).catch(() => null)
: null;
const header = `
<div class="run-header">
<h2>${escapeHtml(run.model || t('tr.unknown_model'))}</h2>
Expand All @@ -351,7 +365,7 @@ async function buildRunView(run, events, compact, objectUrls = new Set()) {
${formatCost(run.totalCost) ? `<span class="stat">${escapeHtml(t('tr.cost.label'))} <b>${escapeHtml(formatCost(run.totalCost))}</b></span>` : ''}
</div>
${run.lossless === true ? `<div class="lossless-warning" role="alert">${escapeHtml(t('tr.lossless.warning'))}</div>` : ''}
${renderConversationPanel(run, compact)}
${renderConversationPanel(run, compact, sessionStats)}
<div class="run-task">${escapeHtml(run.userMessage || '')}</div>
${run.finalContent ? `<div class="run-task" style="border-left-color:var(--success);"><b style="color:var(--success);">${escapeHtml(t('tr.final_label'))}</b> ${escapeHtml(run.finalContent)}</div>` : ''}
`;
Expand Down
Loading
Loading