Skip to content

Commit df0b450

Browse files
waleedlatif1claude
andcommitted
improvement(trace-spans): rewrite trace span pipeline with per-iteration enrichment
Unify tool calls under span.children, capture dual-clock timing, and surface per-iteration model content (assistant text, thinking, tool calls, finish reason, tokens, cost, ttft, provider, errors) across all 12 LLM providers. UI renders the new fields on model child spans; old logs degrade gracefully since every field is optional. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent c191872 commit df0b450

32 files changed

Lines changed: 2382 additions & 1082 deletions

File tree

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx

Lines changed: 244 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ import { Copy as CopyIcon, Search as SearchIcon } from '@/components/emcn/icons'
2020
import { AgentSkillsIcon, WorkflowIcon } from '@/components/icons'
2121
import { cn } from '@/lib/core/utils/cn'
2222
import { formatDuration } from '@/lib/core/utils/formatting'
23+
import type { TraceSpan } from '@/lib/logs/types'
2324
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
2425
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
2526
import { getBlock, getBlockByToolName } from '@/blocks'
2627
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
27-
import type { TraceSpan } from '@/stores/logs/filters/types'
2828

2929
interface TraceSpansProps {
3030
traceSpans?: TraceSpan[]
@@ -58,6 +58,86 @@ function useSetToggle() {
5858
)
5959
}
6060

61+
/**
62+
* Formats a token count with locale-aware thousands separators.
63+
* Returns `undefined` for missing or non-positive counts so callers can
64+
* filter them out before rendering.
65+
*/
66+
function formatTokenCount(value: number | undefined): string | undefined {
67+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
68+
return value.toLocaleString('en-US')
69+
}
70+
71+
/**
72+
* Builds a compact, dot-separated token summary for a span:
73+
* `"1,234 in · 567 out · 1,801 total"` with cache/reasoning appended when
74+
* present. Returns `undefined` when the span has no meaningful token data.
75+
*/
76+
function formatTokensSummary(tokens: TraceSpan['tokens']): string | undefined {
77+
if (!tokens) return undefined
78+
const parts: string[] = []
79+
const input = formatTokenCount(tokens.input)
80+
const output = formatTokenCount(tokens.output)
81+
const total = formatTokenCount(tokens.total)
82+
const cacheRead = formatTokenCount(tokens.cacheRead)
83+
const cacheWrite = formatTokenCount(tokens.cacheWrite)
84+
const reasoning = formatTokenCount(tokens.reasoning)
85+
if (input) parts.push(`${input} in`)
86+
if (cacheRead) parts.push(`${cacheRead} cached`)
87+
if (cacheWrite) parts.push(`${cacheWrite} cache write`)
88+
if (output) parts.push(`${output} out`)
89+
if (reasoning) parts.push(`${reasoning} reasoning`)
90+
if (total) parts.push(`${total} total`)
91+
return parts.length > 0 ? parts.join(' · ') : undefined
92+
}
93+
94+
/**
95+
* Formats a USD cost value for display. Shows `<$0.0001` for non-zero sub-cent
96+
* amounts so the user sees it was counted.
97+
*/
98+
function formatCostAmount(value: number | undefined): string | undefined {
99+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
100+
if (value < 0.0001) return '<$0.0001'
101+
return `$${value.toFixed(4)}`
102+
}
103+
104+
/**
105+
* Builds a compact cost summary: `"$0.0023 · $0.0001 in · $0.0022 out"`.
106+
* Falls back to whichever parts are present.
107+
*/
108+
function formatCostSummary(cost: TraceSpan['cost']): string | undefined {
109+
if (!cost) return undefined
110+
const parts: string[] = []
111+
const total = formatCostAmount(cost.total)
112+
const input = formatCostAmount(cost.input)
113+
const output = formatCostAmount(cost.output)
114+
if (total) parts.push(total)
115+
if (input) parts.push(`${input} in`)
116+
if (output) parts.push(`${output} out`)
117+
return parts.length > 0 ? parts.join(' · ') : undefined
118+
}
119+
120+
/**
121+
* Derives tokens-per-second from output tokens over segment duration.
122+
* Returns `undefined` when inputs are missing or non-positive.
123+
*/
124+
function formatTps(outputTokens: number | undefined, durationMs: number): string | undefined {
125+
if (typeof outputTokens !== 'number' || !(outputTokens > 0)) return undefined
126+
if (!(durationMs > 0)) return undefined
127+
const tps = Math.round(outputTokens / (durationMs / 1000))
128+
if (!(tps > 0)) return undefined
129+
return `${tps.toLocaleString('en-US')} tok/s`
130+
}
131+
132+
/**
133+
* Formats time-to-first-token. Uses `ms` below 1000, `s` above.
134+
*/
135+
function formatTtft(ms: number | undefined): string | undefined {
136+
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return undefined
137+
if (ms < 1000) return `${Math.round(ms)}ms`
138+
return `${(ms / 1000).toFixed(2)}s`
139+
}
140+
61141
/**
62142
* Parses a time value to milliseconds
63143
*/
@@ -185,7 +265,7 @@ function ProgressBar({
185265
const computeSegment = (s: TraceSpan) => {
186266
const startMs = new Date(s.startTime).getTime()
187267
const endMs = new Date(s.endTime).getTime()
188-
const duration = endMs - startMs
268+
const duration = s.duration || endMs - startMs
189269
const startPercent =
190270
totalDuration > 0 ? ((startMs - workflowStartTime) / totalDuration) * 100 : 0
191271
const widthPercent = totalDuration > 0 ? (duration / totalDuration) * 100 : 0
@@ -238,7 +318,7 @@ function InputOutputSection({
238318
data: unknown
239319
isError: boolean
240320
spanId: string
241-
sectionType: 'input' | 'output'
321+
sectionType: 'input' | 'output' | 'thinking' | 'modelToolCalls' | 'errorMessage'
242322
expandedSections: Set<string>
243323
onToggle: (section: string) => void
244324
}) {
@@ -268,6 +348,7 @@ function InputOutputSection({
268348

269349
const jsonString = useMemo(() => {
270350
if (!data) return ''
351+
if (typeof data === 'string') return data
271352
return JSON.stringify(data, null, 2)
272353
}, [data])
273354

@@ -513,63 +594,52 @@ const TraceSpanNode = memo(function TraceSpanNode({
513594

514595
const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type, span.name)
515596

516-
// Build all children including tool calls
517-
const allChildren = useMemo(() => {
518-
const children: TraceSpan[] = []
519-
520-
// Add tool calls as child spans
521-
if (span.toolCalls && span.toolCalls.length > 0) {
522-
span.toolCalls.forEach((toolCall, index) => {
523-
const toolStartTime = toolCall.startTime
524-
? new Date(toolCall.startTime).getTime()
525-
: spanStartTime
526-
const toolEndTime = toolCall.endTime
527-
? new Date(toolCall.endTime).getTime()
528-
: toolStartTime + (toolCall.duration || 0)
529-
530-
children.push({
531-
id: `${spanId}-tool-${index}`,
532-
name: toolCall.name,
597+
const displayChildren = useMemo(() => {
598+
const kids: TraceSpan[] = span.children?.length
599+
? [...span.children]
600+
: (span.toolCalls ?? []).map((tc, i) => ({
601+
id: `${spanId}-tool-${i}`,
602+
name: tc.name,
533603
type: 'tool',
534-
duration: toolCall.duration || toolEndTime - toolStartTime,
535-
startTime: new Date(toolStartTime).toISOString(),
536-
endTime: new Date(toolEndTime).toISOString(),
537-
status: toolCall.error ? ('error' as const) : ('success' as const),
538-
input: toolCall.input,
539-
output: toolCall.error
540-
? { error: toolCall.error, ...(toolCall.output || {}) }
541-
: toolCall.output,
542-
} as TraceSpan)
543-
})
544-
}
545-
546-
// Add regular children
547-
if (span.children && span.children.length > 0) {
548-
children.push(...span.children)
549-
}
604+
duration: tc.duration || 0,
605+
startTime: tc.startTime ?? span.startTime,
606+
endTime: tc.endTime ?? span.endTime,
607+
status: tc.error ? ('error' as const) : ('success' as const),
608+
input: tc.input,
609+
output: tc.error ? { error: tc.error, ...(tc.output ?? {}) } : tc.output,
610+
}))
550611

551-
// Sort by start time
552-
return children.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime))
553-
}, [span, spanId, spanStartTime])
612+
kids.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime))
554613

555-
// Hide empty model timing segments for agents without tool calls
556-
const filteredChildren = useMemo(() => {
557614
const isAgent = span.type?.toLowerCase() === 'agent'
558-
const hasToolCalls =
559-
(span.toolCalls?.length ?? 0) > 0 || allChildren.some((c) => c.type?.toLowerCase() === 'tool')
560-
561-
if (isAgent && !hasToolCalls) {
562-
return allChildren.filter((c) => c.type?.toLowerCase() !== 'model')
615+
const hasToolCall = kids.some((c) => c.type?.toLowerCase() === 'tool')
616+
if (isAgent && !hasToolCall) {
617+
return kids.filter((c) => c.type?.toLowerCase() !== 'model')
563618
}
564-
return allChildren
565-
}, [allChildren, span.type, span.toolCalls])
619+
return kids
620+
}, [span, spanId])
566621

567-
const hasChildren = filteredChildren.length > 0
622+
const hasChildren = displayChildren.length > 0
568623
const isExpanded = isRootWorkflow || expandedNodes.has(spanId)
569624
const isToggleable = !isRootWorkflow
570625

571626
const hasInput = Boolean(span.input)
572627
const hasOutput = Boolean(span.output)
628+
const hasThinking = Boolean(span.thinking)
629+
const hasModelToolCalls = Boolean(span.modelToolCalls && span.modelToolCalls.length > 0)
630+
const hasFinishReason = Boolean(span.finishReason)
631+
const tokensSummary = formatTokensSummary(span.tokens)
632+
const hasTokens = Boolean(tokensSummary)
633+
const costSummary = formatCostSummary(span.cost)
634+
const hasCost = Boolean(costSummary)
635+
const isModelSpan = span.type?.toLowerCase() === 'model'
636+
const tpsSummary = isModelSpan ? formatTps(span.tokens?.output, duration) : undefined
637+
const hasTps = Boolean(tpsSummary)
638+
const ttftSummary = formatTtft(span.ttft)
639+
const hasTtft = Boolean(ttftSummary)
640+
const hasProvider = Boolean(span.provider)
641+
const hasErrorType = Boolean(span.errorType)
642+
const hasErrorMessage = Boolean(span.errorMessage)
573643

574644
// For progress bar - show child segments for workflow/iteration types
575645
const lowerType = span.type?.toLowerCase() || ''
@@ -641,7 +711,18 @@ const TraceSpanNode = memo(function TraceSpanNode({
641711
/>
642712

643713
{/* Input/Output Sections */}
644-
{(hasInput || hasOutput) && (
714+
{(hasInput ||
715+
hasOutput ||
716+
hasThinking ||
717+
hasModelToolCalls ||
718+
hasFinishReason ||
719+
hasTokens ||
720+
hasCost ||
721+
hasTps ||
722+
hasTtft ||
723+
hasProvider ||
724+
hasErrorType ||
725+
hasErrorMessage) && (
645726
<div className='flex min-w-0 flex-col gap-1.5 overflow-hidden py-0.5'>
646727
{hasInput && (
647728
<InputOutputSection
@@ -670,13 +751,125 @@ const TraceSpanNode = memo(function TraceSpanNode({
670751
onToggle={onToggleSection}
671752
/>
672753
)}
754+
755+
{hasThinking && (
756+
<>
757+
{(hasInput || hasOutput) && (
758+
<div className='border-[var(--border)] border-t border-dashed' />
759+
)}
760+
<InputOutputSection
761+
label='Thinking'
762+
data={span.thinking}
763+
isError={false}
764+
spanId={spanId}
765+
sectionType='thinking'
766+
expandedSections={expandedSections}
767+
onToggle={onToggleSection}
768+
/>
769+
</>
770+
)}
771+
772+
{hasModelToolCalls && (
773+
<>
774+
{(hasInput || hasOutput || hasThinking) && (
775+
<div className='border-[var(--border)] border-t border-dashed' />
776+
)}
777+
<InputOutputSection
778+
label='Tool calls'
779+
data={span.modelToolCalls}
780+
isError={false}
781+
spanId={spanId}
782+
sectionType='modelToolCalls'
783+
expandedSections={expandedSections}
784+
onToggle={onToggleSection}
785+
/>
786+
</>
787+
)}
788+
789+
{hasErrorMessage && (
790+
<>
791+
{(hasInput || hasOutput || hasThinking || hasModelToolCalls) && (
792+
<div className='border-[var(--border)] border-t border-dashed' />
793+
)}
794+
<InputOutputSection
795+
label='Error'
796+
data={span.errorMessage}
797+
isError
798+
spanId={spanId}
799+
sectionType='errorMessage'
800+
expandedSections={expandedSections}
801+
onToggle={onToggleSection}
802+
/>
803+
</>
804+
)}
805+
806+
{hasErrorType && (
807+
<div className='flex items-center justify-between gap-2 font-medium text-caption'>
808+
<span className='flex-shrink-0 text-[var(--text-tertiary)]'>Error type</span>
809+
<span className='min-w-0 truncate text-right text-[var(--text-error)]'>
810+
{span.errorType}
811+
</span>
812+
</div>
813+
)}
814+
815+
{hasFinishReason && (
816+
<div className='flex items-center justify-between font-medium text-caption'>
817+
<span className='text-[var(--text-tertiary)]'>Finish reason</span>
818+
<span className='text-[var(--text-secondary)]'>{span.finishReason}</span>
819+
</div>
820+
)}
821+
822+
{hasProvider && (
823+
<div className='flex items-center justify-between gap-2 font-medium text-caption'>
824+
<span className='flex-shrink-0 text-[var(--text-tertiary)]'>Provider</span>
825+
<span className='min-w-0 truncate text-right text-[var(--text-secondary)]'>
826+
{span.provider}
827+
</span>
828+
</div>
829+
)}
830+
831+
{hasTtft && (
832+
<div className='flex items-center justify-between gap-2 font-medium text-caption'>
833+
<span className='flex-shrink-0 text-[var(--text-tertiary)]'>TTFT</span>
834+
<span className='min-w-0 truncate text-right text-[var(--text-secondary)]'>
835+
{ttftSummary}
836+
</span>
837+
</div>
838+
)}
839+
840+
{hasTokens && (
841+
<div className='flex items-center justify-between gap-2 font-medium text-caption'>
842+
<span className='flex-shrink-0 text-[var(--text-tertiary)]'>Tokens</span>
843+
<span className='min-w-0 truncate text-right text-[var(--text-secondary)]'>
844+
{tokensSummary}
845+
</span>
846+
</div>
847+
)}
848+
849+
{hasTps && (
850+
<div className='flex items-center justify-between gap-2 font-medium text-caption'>
851+
<span className='flex-shrink-0 text-[var(--text-tertiary)]'>Throughput</span>
852+
<span className='min-w-0 truncate text-right text-[var(--text-secondary)]'>
853+
{tpsSummary}
854+
</span>
855+
</div>
856+
)}
857+
858+
{hasCost && (
859+
<div className='flex items-center justify-between gap-2 font-medium text-caption'>
860+
<span className='flex-shrink-0 text-[var(--text-tertiary)]'>Cost</span>
861+
<span className='min-w-0 truncate text-right text-[var(--text-secondary)]'>
862+
{costSummary}
863+
</span>
864+
</div>
865+
)}
673866
</div>
674867
)}
675868

676869
{/* Nested Children */}
677870
{hasChildren && (
678871
<div className='flex min-w-0 flex-col gap-0.5 border-[var(--border)] border-l pl-2.5'>
679-
{filteredChildren.map((child, index) => (
872+
{displayChildren.map((child, index) => (
680873
<div key={child.id || `${spanId}-child-${index}`} className='pl-1.5'>
681874
<TraceSpanNode
682875
span={child}

0 commit comments

Comments
 (0)