mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix(trace): show tool call durations (#799)
Compute tool spans from paired tool results, surface session wall/model/tool timing, and keep pending spans updated with elapsed time. Tested: cd desktop && bun run test -- --run src/lib/traceViewModel.test.ts src/pages/TraceSession.test.tsx Tested: cd desktop && bun run test -- --run src/pages/TraceList.test.tsx Tested: bun run check:desktop Scope-risk: narrow
This commit is contained in:
parent
58d5221ca1
commit
00b4ead0ca
@ -78,8 +78,11 @@ function HeaderChips({ span }: { span: TraceSpan }) {
|
||||
<>
|
||||
{call.model ? <MetaChip label={t('trace.model')} value={call.model} /> : null}
|
||||
{call.provider?.name ? <MetaChip label={t('trace.provider')} value={call.provider.name} /> : null}
|
||||
{call.durationMs !== undefined ? (
|
||||
<MetaChip label={t('trace.duration')} value={formatDurationMs(call.durationMs)} />
|
||||
{span.durationMs !== undefined ? (
|
||||
<MetaChip
|
||||
label={span.status === 'pending' ? t('trace.elapsed') : t('trace.duration')}
|
||||
value={formatDurationMs(span.durationMs)}
|
||||
/>
|
||||
) : null}
|
||||
{call.usage ? (
|
||||
<MetaChip
|
||||
@ -100,6 +103,7 @@ function HeaderChips({ span }: { span: TraceSpan }) {
|
||||
</>
|
||||
) : null}
|
||||
<MetaChip label={t('trace.started')} value={formatClockTime(call.startedAt)} />
|
||||
{span.completedAt ? <MetaChip label={t('trace.completed')} value={formatClockTime(span.completedAt)} /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -107,13 +111,23 @@ function HeaderChips({ span }: { span: TraceSpan }) {
|
||||
<>
|
||||
{span.toolUseId ? <MetaChip label={t('trace.detail.toolUseId')} value={span.toolUseId} /> : null}
|
||||
{span.durationMs !== undefined ? (
|
||||
<MetaChip label={t('trace.duration')} value={formatDurationMs(span.durationMs)} />
|
||||
<MetaChip
|
||||
label={durationLabelForSpan(span, t)}
|
||||
value={formatDurationMs(span.durationMs)}
|
||||
/>
|
||||
) : null}
|
||||
<MetaChip label={t('trace.started')} value={formatClockTime(span.timestamp)} />
|
||||
{span.completedAt ? <MetaChip label={t('trace.completed')} value={formatClockTime(span.completedAt)} /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function durationLabelForSpan(span: TraceSpan, t: ReturnType<typeof useTranslation>): string {
|
||||
if (span.status === 'pending') return t('trace.elapsed')
|
||||
if (span.kind === 'session' || span.kind === 'turn') return t('trace.wallTime')
|
||||
return t('trace.duration')
|
||||
}
|
||||
|
||||
function usageTooltip(usage: NonNullable<TraceSpan['tokenUsage']>): string {
|
||||
const parts = [
|
||||
`in ${formatTokenCount(usage.inputTokens)}`,
|
||||
|
||||
@ -8,7 +8,9 @@ type OverviewStats = {
|
||||
llmCalls: number
|
||||
toolCalls: number
|
||||
errors: number
|
||||
durationMs?: number
|
||||
wallDurationMs?: number
|
||||
modelDurationMs?: number
|
||||
toolDurationMs?: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
models: string[]
|
||||
@ -35,7 +37,9 @@ export function SessionOverview({
|
||||
<Stat label={t('trace.llmCalls')} value={String(stats.llmCalls)} />
|
||||
<Stat label={t('trace.toolCalls')} value={String(stats.toolCalls)} />
|
||||
<Stat label={t('trace.errors')} value={String(stats.errors)} tone={stats.errors > 0 ? 'danger' : 'default'} />
|
||||
<Stat label={t('trace.duration')} value={formatDurationMs(stats.durationMs)} />
|
||||
<Stat label={t('trace.wallTime')} value={formatDurationMs(stats.wallDurationMs)} />
|
||||
<Stat label={t('trace.modelTime')} value={formatDurationMs(stats.modelDurationMs)} />
|
||||
<Stat label={t('trace.toolTime')} value={formatDurationMs(stats.toolDurationMs)} />
|
||||
<Stat
|
||||
label={t('trace.tokens')}
|
||||
value={`${formatTokenCount(stats.inputTokens)} → ${formatTokenCount(stats.outputTokens)}`}
|
||||
@ -99,23 +103,28 @@ function computeStats(span: TraceSpan, viewModel: TraceViewModel): OverviewStats
|
||||
models: [],
|
||||
}
|
||||
const models = new Set<string>()
|
||||
let llmDuration = 0
|
||||
let modelDurationMs = 0
|
||||
let toolDurationMs = 0
|
||||
for (const item of scoped) {
|
||||
if (item.kind === 'llm') {
|
||||
stats.llmCalls += 1
|
||||
if (item.call?.model) models.add(item.call.model)
|
||||
if (item.durationMs !== undefined) llmDuration += item.durationMs
|
||||
if (item.durationMs !== undefined) modelDurationMs += item.durationMs
|
||||
if (item.tokenUsage) {
|
||||
stats.inputTokens += item.tokenUsage.inputTokens
|
||||
stats.outputTokens += item.tokenUsage.outputTokens
|
||||
}
|
||||
}
|
||||
if (item.kind === 'tool') stats.toolCalls += 1
|
||||
if (item.kind === 'tool') {
|
||||
stats.toolCalls += 1
|
||||
if (item.durationMs !== undefined) toolDurationMs += item.durationMs
|
||||
}
|
||||
if (item.status === 'error') stats.errors += 1
|
||||
}
|
||||
stats.models = [...models]
|
||||
const durationMs = span.kind === 'session' ? span.durationMs ?? llmDuration : llmDuration
|
||||
if (durationMs > 0) stats.durationMs = durationMs
|
||||
if (span.durationMs !== undefined && span.durationMs > 0) stats.wallDurationMs = span.durationMs
|
||||
if (modelDurationMs > 0) stats.modelDurationMs = modelDurationMs
|
||||
if (toolDurationMs > 0) stats.toolDurationMs = toolDurationMs
|
||||
return stats
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import type { TraceSpan } from '../../../lib/traceViewModel'
|
||||
import { formatTraceJson } from '../../../lib/traceViewModel'
|
||||
import { formatClockTime } from '../../../lib/trace/formatters'
|
||||
import { formatClockTime, formatDurationMs } from '../../../lib/trace/formatters'
|
||||
import { CodeViewer } from '../../chat/CodeViewer'
|
||||
import { Section } from './Section'
|
||||
|
||||
@ -44,6 +44,15 @@ export function ToolDetail({ span }: { span: TraceSpan }) {
|
||||
value={span.status === 'error' ? t('trace.status.error') : span.status === 'pending' ? t('trace.status.pending') : t('trace.status.ok')}
|
||||
/>
|
||||
<MetaRow label={t('trace.started')} value={formatClockTime(span.timestamp)} />
|
||||
{span.completedAt ? (
|
||||
<MetaRow label={t('trace.completed')} value={formatClockTime(span.completedAt)} />
|
||||
) : null}
|
||||
{span.durationMs !== undefined ? (
|
||||
<MetaRow
|
||||
label={span.status === 'pending' ? t('trace.elapsed') : t('trace.duration')}
|
||||
value={formatDurationMs(span.durationMs)}
|
||||
/>
|
||||
) : null}
|
||||
</dl>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
@ -1675,6 +1675,11 @@ export const en = {
|
||||
'trace.apiCalls': 'API calls',
|
||||
'trace.failedCalls': 'Failed',
|
||||
'trace.duration': 'Duration',
|
||||
'trace.elapsed': 'Elapsed',
|
||||
'trace.completed': 'Completed',
|
||||
'trace.wallTime': 'Wall time',
|
||||
'trace.modelTime': 'Model time',
|
||||
'trace.toolTime': 'Tool time',
|
||||
'trace.models': 'Models',
|
||||
'trace.request': 'Request',
|
||||
'trace.response': 'Response',
|
||||
|
||||
@ -1677,6 +1677,11 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'trace.apiCalls': 'API 呼び出し',
|
||||
'trace.failedCalls': '失敗',
|
||||
'trace.duration': '所要時間',
|
||||
'trace.elapsed': '経過時間',
|
||||
'trace.completed': '完了',
|
||||
'trace.wallTime': '実時間',
|
||||
'trace.modelTime': 'モデル時間',
|
||||
'trace.toolTime': 'ツール時間',
|
||||
'trace.models': 'モデル',
|
||||
'trace.request': 'リクエスト',
|
||||
'trace.response': 'レスポンス',
|
||||
|
||||
@ -1677,6 +1677,11 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'trace.apiCalls': 'API 호출',
|
||||
'trace.failedCalls': '실패',
|
||||
'trace.duration': '소요 시간',
|
||||
'trace.elapsed': '경과 시간',
|
||||
'trace.completed': '완료',
|
||||
'trace.wallTime': '실제 시간',
|
||||
'trace.modelTime': '모델 시간',
|
||||
'trace.toolTime': '도구 시간',
|
||||
'trace.models': '모델',
|
||||
'trace.request': '요청',
|
||||
'trace.response': '응답',
|
||||
|
||||
@ -1677,6 +1677,11 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'trace.apiCalls': 'API 呼叫',
|
||||
'trace.failedCalls': '失敗',
|
||||
'trace.duration': '耗時',
|
||||
'trace.elapsed': '已用時',
|
||||
'trace.completed': '完成',
|
||||
'trace.wallTime': '牆鐘時間',
|
||||
'trace.modelTime': '模型耗時',
|
||||
'trace.toolTime': '工具耗時',
|
||||
'trace.models': '模型',
|
||||
'trace.request': '請求',
|
||||
'trace.response': '回應',
|
||||
|
||||
@ -1677,6 +1677,11 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'trace.apiCalls': 'API 调用',
|
||||
'trace.failedCalls': '失败',
|
||||
'trace.duration': '耗时',
|
||||
'trace.elapsed': '已用时',
|
||||
'trace.completed': '完成',
|
||||
'trace.wallTime': '墙钟时间',
|
||||
'trace.modelTime': '模型耗时',
|
||||
'trace.toolTime': '工具耗时',
|
||||
'trace.models': '模型',
|
||||
'trace.request': '请求',
|
||||
'trace.response': '响应',
|
||||
|
||||
@ -121,6 +121,8 @@ describe('traceViewModel', () => {
|
||||
status: 'ok',
|
||||
title: 'Bash',
|
||||
subtitle: 'ls -la /tmp',
|
||||
completedAt: '2026-06-09T10:00:03.000Z',
|
||||
durationMs: 1000,
|
||||
})
|
||||
expect(tool?.childIds[0]).toMatch(/^tool_result:/)
|
||||
expect(viewModel.spansById.get(tool?.childIds[0] ?? '')).toMatchObject({
|
||||
@ -136,6 +138,41 @@ describe('traceViewModel', () => {
|
||||
expect(viewModel.spansById.get('llm:call-1')?.childIds).toContain('event:event-1')
|
||||
})
|
||||
|
||||
it('calculates wall-clock timing for turns and the session from span end times', () => {
|
||||
const viewModel = buildTraceViewModel(trace, messages)
|
||||
|
||||
expect(viewModel.spansById.get('turn:0')).toMatchObject({
|
||||
completedAt: '2026-06-09T10:00:04.700Z',
|
||||
durationMs: 4700,
|
||||
})
|
||||
expect(viewModel.spansById.get(viewModel.rootId)).toMatchObject({
|
||||
completedAt: '2026-06-09T10:00:04.700Z',
|
||||
durationMs: 4700,
|
||||
})
|
||||
expect(viewModel.diagnosis.lastActivityAt).toBe('2026-06-09T10:00:04.700Z')
|
||||
})
|
||||
|
||||
it('shows elapsed time for pending tools without marking them completed', () => {
|
||||
const viewModel = buildTraceViewModel(trace, messages.slice(0, 2), {
|
||||
now: '2026-06-09T10:00:07.000Z',
|
||||
})
|
||||
|
||||
expect(viewModel.spansById.get('tool:tool-1')).toMatchObject({
|
||||
status: 'pending',
|
||||
durationMs: 5000,
|
||||
})
|
||||
expect(viewModel.spansById.get('tool:tool-1')?.completedAt).toBeUndefined()
|
||||
expect(viewModel.spansById.get('turn:0')).toMatchObject({
|
||||
status: 'pending',
|
||||
durationMs: 7000,
|
||||
})
|
||||
expect(viewModel.spansById.get(viewModel.rootId)).toMatchObject({
|
||||
status: 'pending',
|
||||
durationMs: 7000,
|
||||
})
|
||||
expect(viewModel.diagnosis.lastActivityAt).toBe('2026-06-09T10:00:07.000Z')
|
||||
})
|
||||
|
||||
it('passes call usage through to llm spans as tokenUsage', () => {
|
||||
const viewModel = buildTraceViewModel(trace, messages)
|
||||
|
||||
|
||||
@ -70,6 +70,10 @@ export type TraceViewModel = {
|
||||
diagnosis: TraceDiagnosis
|
||||
}
|
||||
|
||||
export type TraceViewModelOptions = {
|
||||
now?: string | number | Date
|
||||
}
|
||||
|
||||
type ToolUseBlock = {
|
||||
type: 'tool_use'
|
||||
id?: string
|
||||
@ -90,15 +94,21 @@ type TextBlock = {
|
||||
}
|
||||
|
||||
type MutableSpan = Omit<TraceSpan, 'childIds'> & { childIds: string[] }
|
||||
type ToolResultOccurrence = {
|
||||
message: MessageEntry
|
||||
block: ToolResultBlock
|
||||
}
|
||||
|
||||
export function buildTraceViewModel(
|
||||
trace: TraceSessionData,
|
||||
messages: MessageEntry[],
|
||||
options: TraceViewModelOptions = {},
|
||||
): TraceViewModel {
|
||||
const spans = new Map<string, MutableSpan>()
|
||||
const turns: TraceTurn[] = []
|
||||
const rootId = 'session:root'
|
||||
const traceEvents = trace.events ?? []
|
||||
const nowTimestamp = normalizeNowTimestamp(options.now)
|
||||
const fallbackTimestamp = earliestTimestamp(trace.calls, messages, traceEvents) ?? new Date().toISOString()
|
||||
const hasPendingCalls = trace.calls.some((call) => getCallStatus(call) === 'pending')
|
||||
const hasTraceErrors = trace.summary.failedCalls > 0 || traceEvents.some((event) => event.severity === 'error')
|
||||
@ -112,7 +122,6 @@ export function buildTraceViewModel(
|
||||
subtitle: `${trace.summary.apiCalls} model calls`,
|
||||
timestamp: fallbackTimestamp,
|
||||
completedAt: trace.summary.updatedAt ?? undefined,
|
||||
durationMs: trace.summary.totalDurationMs,
|
||||
raw: {
|
||||
sessionId: trace.sessionId,
|
||||
summary: trace.summary,
|
||||
@ -145,7 +154,7 @@ export function buildTraceViewModel(
|
||||
}
|
||||
|
||||
const toolSpanIds = new Map<string, string>()
|
||||
const resultBlocksByToolUseId = new Map<string, ToolResultBlock[]>()
|
||||
const resultBlocksByToolUseId = new Map<string, ToolResultOccurrence[]>()
|
||||
const deferredToolResultSpans: Array<{ parentId: string; message: MessageEntry; block: ToolResultBlock }> = []
|
||||
|
||||
for (const message of messages) {
|
||||
@ -164,7 +173,7 @@ export function buildTraceViewModel(
|
||||
for (const block of resultBlocks) {
|
||||
if (block.tool_use_id) {
|
||||
const existing = resultBlocksByToolUseId.get(block.tool_use_id) ?? []
|
||||
existing.push(block)
|
||||
existing.push({ message, block })
|
||||
resultBlocksByToolUseId.set(block.tool_use_id, existing)
|
||||
}
|
||||
const toolParentId = block.tool_use_id ? toolSpanIds.get(block.tool_use_id) : undefined
|
||||
@ -197,20 +206,29 @@ export function buildTraceViewModel(
|
||||
const spanId = `tool:${toolId}`
|
||||
toolSpanIds.set(toolId, spanId)
|
||||
const resultBlocks = block.id ? resultBlocksByToolUseId.get(block.id) ?? [] : []
|
||||
const completion = latestToolResultTimestamp(message.timestamp, resultBlocks)
|
||||
const status = resultBlocks.some((result) => result.block.is_error)
|
||||
? 'error'
|
||||
: resultBlocks.length > 0
|
||||
? 'ok'
|
||||
: 'pending'
|
||||
addSpan(spans, {
|
||||
id: spanId,
|
||||
parentId,
|
||||
kind: 'tool',
|
||||
status: resultBlocks.some((result) => result.is_error) ? 'error' : resultBlocks.length > 0 ? 'ok' : 'pending',
|
||||
status,
|
||||
title: block.name ?? 'Tool call',
|
||||
subtitle: summarizeToolInput(block.input),
|
||||
timestamp: message.timestamp,
|
||||
...spanTimingFromTimestamps(message.timestamp, completion ?? (status === 'pending' ? nowTimestamp : undefined), {
|
||||
completed: Boolean(completion),
|
||||
}),
|
||||
turnIndex: turn.index,
|
||||
message,
|
||||
toolUseId: toolId,
|
||||
toolName: block.name,
|
||||
input: block.input,
|
||||
output: resultBlocks.map((result) => result.content),
|
||||
output: resultBlocks.map((result) => result.block.content),
|
||||
isSidechain: message.isSidechain,
|
||||
raw: block,
|
||||
})
|
||||
@ -229,16 +247,24 @@ export function buildTraceViewModel(
|
||||
for (const call of trace.calls) {
|
||||
const turn = findTurnForTimestamp(turns, call.startedAt)
|
||||
const spanId = `llm:${call.id}`
|
||||
const status = getCallStatus(call)
|
||||
const derivedCallTiming = call.durationMs !== undefined
|
||||
? {
|
||||
...(call.completedAt ? { completedAt: call.completedAt } : {}),
|
||||
durationMs: call.durationMs,
|
||||
}
|
||||
: spanTimingFromTimestamps(call.startedAt, call.completedAt ?? (status === 'pending' ? nowTimestamp : undefined), {
|
||||
completed: Boolean(call.completedAt),
|
||||
})
|
||||
addSpan(spans, {
|
||||
id: spanId,
|
||||
parentId: turn.id,
|
||||
kind: 'llm',
|
||||
status: getCallStatus(call),
|
||||
status,
|
||||
title: call.model ?? call.provider?.name ?? 'Model call',
|
||||
subtitle: call.provider?.name ?? call.source,
|
||||
timestamp: call.startedAt,
|
||||
completedAt: call.completedAt,
|
||||
durationMs: call.durationMs,
|
||||
...derivedCallTiming,
|
||||
turnIndex: turn.index,
|
||||
call,
|
||||
tokenUsage: call.usage,
|
||||
@ -280,9 +306,23 @@ export function buildTraceViewModel(
|
||||
? 'pending'
|
||||
: 'ok'
|
||||
turnSpan.subtitle = `${turn.spanIds.length} spans`
|
||||
applyAggregateTiming(turnSpan, turn.spanIds.map((spanId) => spans.get(spanId)).filter(Boolean) as MutableSpan[])
|
||||
}
|
||||
}
|
||||
|
||||
const rootSpan = spans.get(rootId)
|
||||
if (rootSpan) {
|
||||
const childStatuses = rootSpan.childIds
|
||||
.map((spanId) => spans.get(spanId)?.status)
|
||||
.filter(Boolean)
|
||||
rootSpan.status = childStatuses.includes('error')
|
||||
? 'error'
|
||||
: childStatuses.includes('pending')
|
||||
? 'pending'
|
||||
: rootSpan.status
|
||||
applyAggregateTiming(rootSpan, rootSpan.childIds.map((spanId) => spans.get(spanId)).filter(Boolean) as MutableSpan[])
|
||||
}
|
||||
|
||||
const orderedSpanIds = orderSpans(spans, rootId)
|
||||
const spanList = orderedSpanIds.map((id) => spans.get(id)).filter(Boolean) as TraceSpan[]
|
||||
return {
|
||||
@ -310,7 +350,7 @@ function buildDiagnosis(
|
||||
const eventErrors = errorSpans.filter((span) => span.kind === 'event')
|
||||
const lastSpan = meaningfulSpans
|
||||
.slice()
|
||||
.sort((a, b) => compareSpanTime(a, b))
|
||||
.sort((a, b) => compareSpanActivityTime(a, b))
|
||||
.at(-1)
|
||||
const lastTurn = turns.at(-1)
|
||||
const lastTurnSpans = lastTurn
|
||||
@ -374,14 +414,15 @@ function createDiagnosis(
|
||||
const pendingTools = spans.filter((span) => span.kind === 'tool' && span.status === 'pending')
|
||||
const lastActivityAt = spans
|
||||
.slice()
|
||||
.sort((a, b) => compareSpanTime(a, b))
|
||||
.at(-1)?.timestamp ?? fallbackTimestamp
|
||||
.sort((a, b) => compareSpanActivityTime(a, b))
|
||||
.at(-1)
|
||||
const lastActivityTimestamp = lastActivityAt ? spanActivityTimestamp(lastActivityAt) : undefined
|
||||
return {
|
||||
status,
|
||||
reason,
|
||||
focusSpanId: evidence[0]?.id,
|
||||
evidenceSpanIds: evidence.map((span) => span.id),
|
||||
lastActivityAt,
|
||||
lastActivityAt: lastActivityTimestamp ?? fallbackTimestamp,
|
||||
errorCount: errors.length,
|
||||
pendingModelCalls: pendingModels.length,
|
||||
pendingToolCalls: pendingTools.length,
|
||||
@ -473,6 +514,70 @@ function addToolResultSpan(
|
||||
parent.status = block.is_error ? 'error' : 'ok'
|
||||
const existingOutput = Array.isArray(parent.output) ? parent.output : parent.output === undefined ? [] : [parent.output]
|
||||
parent.output = [...existingOutput, block.content]
|
||||
applyToolCompletion(parent, message.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
function latestToolResultTimestamp(startedAt: string, results: ToolResultOccurrence[]): string | undefined {
|
||||
let latest: { value: string; time: number } | undefined
|
||||
const startTime = new Date(startedAt).getTime()
|
||||
if (!Number.isFinite(startTime)) return undefined
|
||||
for (const result of results) {
|
||||
const time = new Date(result.message.timestamp).getTime()
|
||||
if (!Number.isFinite(time) || time < startTime) continue
|
||||
if (!latest || time > latest.time) {
|
||||
latest = { value: result.message.timestamp, time }
|
||||
}
|
||||
}
|
||||
return latest?.value
|
||||
}
|
||||
|
||||
function spanTimingFromTimestamps(
|
||||
startedAt: string,
|
||||
endedAt?: string,
|
||||
options: { completed?: boolean } = {},
|
||||
): Pick<TraceSpan, 'completedAt' | 'durationMs'> {
|
||||
if (!endedAt) return {}
|
||||
const startTime = new Date(startedAt).getTime()
|
||||
const endTime = new Date(endedAt).getTime()
|
||||
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime < startTime) return {}
|
||||
return {
|
||||
...(options.completed ? { completedAt: endedAt } : {}),
|
||||
durationMs: endTime - startTime,
|
||||
}
|
||||
}
|
||||
|
||||
function applyToolCompletion(parent: MutableSpan, completedAt: string) {
|
||||
const timing = spanTimingFromTimestamps(parent.timestamp, completedAt, { completed: true })
|
||||
if (!timing.completedAt || timing.durationMs === undefined) return
|
||||
const currentCompletedTime = parent.completedAt ? new Date(parent.completedAt).getTime() : Number.NEGATIVE_INFINITY
|
||||
const nextCompletedTime = new Date(timing.completedAt).getTime()
|
||||
if (Number.isFinite(currentCompletedTime) && currentCompletedTime > nextCompletedTime) return
|
||||
parent.completedAt = timing.completedAt
|
||||
parent.durationMs = timing.durationMs
|
||||
}
|
||||
|
||||
function applyAggregateTiming(parent: MutableSpan, children: MutableSpan[]) {
|
||||
const startTime = new Date(parent.timestamp).getTime()
|
||||
if (!Number.isFinite(startTime)) return
|
||||
let endTime = startTime
|
||||
let completedAt: string | undefined
|
||||
|
||||
for (const child of children) {
|
||||
const activity = spanActivity(child)
|
||||
if (!activity) continue
|
||||
if (activity.time >= endTime) {
|
||||
endTime = activity.time
|
||||
completedAt = activity.timestamp
|
||||
}
|
||||
}
|
||||
|
||||
if (endTime < startTime) return
|
||||
parent.durationMs = endTime - startTime
|
||||
if (parent.status !== 'pending' && completedAt) {
|
||||
parent.completedAt = completedAt
|
||||
} else if (parent.status === 'pending') {
|
||||
delete parent.completedAt
|
||||
}
|
||||
}
|
||||
|
||||
@ -507,6 +612,34 @@ function compareSpanTime(a?: MutableSpan, b?: MutableSpan): number {
|
||||
return a.id.localeCompare(b.id)
|
||||
}
|
||||
|
||||
function compareSpanActivityTime(a?: TraceSpan, b?: TraceSpan): number {
|
||||
if (!a || !b) return 0
|
||||
const aActivity = spanActivity(a)
|
||||
const bActivity = spanActivity(b)
|
||||
if (aActivity && bActivity && aActivity.time !== bActivity.time) return aActivity.time - bActivity.time
|
||||
if (aActivity && !bActivity) return 1
|
||||
if (!aActivity && bActivity) return -1
|
||||
return compareSpanTime(a as MutableSpan, b as MutableSpan)
|
||||
}
|
||||
|
||||
function spanActivityTimestamp(span: TraceSpan): string | undefined {
|
||||
return spanActivity(span)?.timestamp
|
||||
}
|
||||
|
||||
function spanActivity(span: TraceSpan): { timestamp: string; time: number } | undefined {
|
||||
const startTime = new Date(span.timestamp).getTime()
|
||||
if (!Number.isFinite(startTime)) return undefined
|
||||
if (span.completedAt) {
|
||||
const completedTime = new Date(span.completedAt).getTime()
|
||||
if (Number.isFinite(completedTime)) return { timestamp: span.completedAt, time: completedTime }
|
||||
}
|
||||
if (span.durationMs !== undefined && Number.isFinite(span.durationMs) && span.durationMs >= 0) {
|
||||
const endTime = startTime + span.durationMs
|
||||
return { timestamp: new Date(endTime).toISOString(), time: endTime }
|
||||
}
|
||||
return { timestamp: span.timestamp, time: startTime }
|
||||
}
|
||||
|
||||
function createTurn(index: number, timestamp: string, title: string): TraceTurn {
|
||||
return {
|
||||
id: `turn:${index}`,
|
||||
@ -551,6 +684,12 @@ function earliestTimestamp(
|
||||
return timestamps[0]?.value ?? null
|
||||
}
|
||||
|
||||
function normalizeNowTimestamp(value: TraceViewModelOptions['now']): string | undefined {
|
||||
if (value === undefined) return new Date().toISOString()
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
|
||||
}
|
||||
|
||||
function hasToolResultBlocks(content: unknown): boolean {
|
||||
return extractToolResultBlocks(content).length > 0
|
||||
}
|
||||
|
||||
@ -310,7 +310,7 @@ function TraceRow({
|
||||
</div>
|
||||
<div className="grid shrink-0 grid-cols-[3.5rem_4rem_4rem] items-center gap-3">
|
||||
<MetricCell label={t('trace.apiCalls')} value={String(trace.summary.apiCalls)} />
|
||||
<MetricCell label={t('trace.duration')} value={formatDuration(trace.summary.totalDurationMs)} />
|
||||
<MetricCell label={t('trace.modelTime')} value={formatDuration(trace.summary.totalDurationMs)} />
|
||||
<MetricCell label={t('trace.tokens')} value={formatCompact(totalTokens)} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@ -200,6 +200,12 @@ describe('TraceSession', () => {
|
||||
const detail = within(screen.getByTestId('trace-detail'))
|
||||
expect(detail.getByTestId('trace-overview')).toBeInTheDocument()
|
||||
expect(detail.getByText('LLM calls')).toBeInTheDocument()
|
||||
expect(detail.getAllByText('Wall time').length).toBeGreaterThan(0)
|
||||
expect(detail.getAllByText('6.00s').length).toBeGreaterThan(0)
|
||||
expect(detail.getAllByText('Model time').length).toBeGreaterThan(0)
|
||||
expect(detail.getAllByText('2.00s').length).toBeGreaterThan(0)
|
||||
expect(detail.getByText('Tool time')).toBeInTheDocument()
|
||||
expect(detail.getAllByText('1.00s').length).toBeGreaterThan(0)
|
||||
|
||||
// Timeline rows for messages, the model call, and the tool call.
|
||||
const tree = within(screen.getByTestId('trace-tree'))
|
||||
@ -238,6 +244,9 @@ describe('TraceSession', () => {
|
||||
|
||||
const detail = within(screen.getByTestId('trace-detail'))
|
||||
expect(detail.getByRole('heading', { level: 2, name: 'Bash' })).toBeInTheDocument()
|
||||
expect(detail.getByText('Duration')).toBeInTheDocument()
|
||||
expect(detail.getByText('1.00s')).toBeInTheDocument()
|
||||
expect(detail.getAllByText('Completed').length).toBeGreaterThan(0)
|
||||
expect(detail.getByTestId('trace-tool-detail')).toBeInTheDocument()
|
||||
expect(detail.getByText('Input')).toBeInTheDocument()
|
||||
expect(detail.getByText('Result')).toBeInTheDocument()
|
||||
|
||||
@ -52,6 +52,7 @@ export function TraceSession({
|
||||
const [refreshNonce, setRefreshNonce] = useState(0)
|
||||
const [lastLoadedAt, setLastLoadedAt] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [clockNowMs, setClockNowMs] = useState(() => Date.now())
|
||||
const snapshotSignatureRef = useRef<string | null>(null)
|
||||
const lastSpanIdRef = useRef<string | null>(null)
|
||||
|
||||
@ -75,6 +76,7 @@ export function TraceSession({
|
||||
if (silent && snapshotSignatureRef.current === signature) return
|
||||
snapshotSignatureRef.current = signature
|
||||
setState({ status: 'ready', trace, messages: messageResponse.messages })
|
||||
setClockNowMs(Date.now())
|
||||
setLastLoadedAt(new Date().toISOString())
|
||||
} catch (error) {
|
||||
if (cancelled) return
|
||||
@ -112,10 +114,19 @@ export function TraceSession({
|
||||
|
||||
const readyState = state.status === 'ready' ? state : null
|
||||
const viewModel = useMemo(
|
||||
() => readyState ? buildTraceViewModel(readyState.trace, readyState.messages) : null,
|
||||
[readyState],
|
||||
() => readyState
|
||||
? buildTraceViewModel(readyState.trace, readyState.messages, { now: new Date(clockNowMs).toISOString() })
|
||||
: null,
|
||||
[readyState, clockNowMs],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewModel) return
|
||||
if (viewModel.diagnosis.pendingModelCalls === 0 && viewModel.diagnosis.pendingToolCalls === 0) return
|
||||
const timer = window.setInterval(() => setClockNowMs(Date.now()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [viewModel?.diagnosis.pendingModelCalls, viewModel?.diagnosis.pendingToolCalls])
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewModel) return
|
||||
const lastSpanId = viewModel.orderedSpanIds.at(-1) ?? null
|
||||
@ -284,7 +295,7 @@ function TraceHeader({
|
||||
{summary.failedCalls > 0 ? (
|
||||
<MetaChip label={t('trace.failedCalls')} value={String(summary.failedCalls)} tone="danger" />
|
||||
) : null}
|
||||
<MetaChip label={t('trace.duration')} value={formatDurationMs(summary.totalDurationMs)} />
|
||||
<MetaChip label={t('trace.modelTime')} value={formatDurationMs(summary.totalDurationMs)} />
|
||||
<MetaChip
|
||||
label={t('trace.tokens')}
|
||||
value={formatTokenCount(summary.totalInputTokens + summary.totalOutputTokens)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user