Skip to content

Commit 7beb4a1

Browse files
authored
feat(logs): added sub-workflow logs, updated trace spans UI, fix scroll behavior in workflow registry sidebar (#1037)
* added sub-workflow logs * indent input/output in trace spans display * better color scheme for workflow logs * scroll behavior in sidebar updated * cleanup * fixed failing tests
1 parent d6549d8 commit 7beb4a1

File tree

6 files changed

+254
-86
lines changed

6 files changed

+254
-86
lines changed

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

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,21 @@ function transformBlockData(data: any, blockType: string, isInput: boolean) {
8282
interface CollapsibleInputOutputProps {
8383
span: TraceSpan
8484
spanId: string
85+
depth: number
8586
}
8687

87-
function CollapsibleInputOutput({ span, spanId }: CollapsibleInputOutputProps) {
88+
function CollapsibleInputOutput({ span, spanId, depth }: CollapsibleInputOutputProps) {
8889
const [inputExpanded, setInputExpanded] = useState(false)
8990
const [outputExpanded, setOutputExpanded] = useState(false)
9091

92+
// Calculate the left margin based on depth to match the parent span's indentation
93+
const leftMargin = depth * 16 + 8 + 24 // Base depth indentation + icon width + extra padding
94+
9195
return (
92-
<div className='mt-2 mr-4 mb-4 ml-8 space-y-3 overflow-hidden'>
96+
<div
97+
className='mt-2 mr-4 mb-4 space-y-3 overflow-hidden'
98+
style={{ marginLeft: `${leftMargin}px` }}
99+
>
93100
{/* Input Data - Collapsible */}
94101
{span.input && (
95102
<div>
@@ -162,26 +169,30 @@ function BlockDataDisplay({
162169
if (value === undefined) return <span className='text-muted-foreground italic'>undefined</span>
163170

164171
if (typeof value === 'string') {
165-
return <span className='break-all text-green-700 dark:text-green-400'>"{value}"</span>
172+
return <span className='break-all text-emerald-700 dark:text-emerald-400'>"{value}"</span>
166173
}
167174

168175
if (typeof value === 'number') {
169-
return <span className='text-blue-700 dark:text-blue-400'>{value}</span>
176+
return <span className='font-mono text-blue-700 dark:text-blue-400'>{value}</span>
170177
}
171178

172179
if (typeof value === 'boolean') {
173-
return <span className='text-purple-700 dark:text-purple-400'>{value.toString()}</span>
180+
return (
181+
<span className='font-mono text-amber-700 dark:text-amber-400'>{value.toString()}</span>
182+
)
174183
}
175184

176185
if (Array.isArray(value)) {
177186
if (value.length === 0) return <span className='text-muted-foreground'>[]</span>
178187
return (
179-
<div className='space-y-1'>
188+
<div className='space-y-0.5'>
180189
<span className='text-muted-foreground'>[</span>
181-
<div className='ml-4 space-y-1'>
190+
<div className='ml-2 space-y-0.5'>
182191
{value.map((item, index) => (
183-
<div key={index} className='flex min-w-0 gap-2'>
184-
<span className='flex-shrink-0 text-muted-foreground text-xs'>{index}:</span>
192+
<div key={index} className='flex min-w-0 gap-1.5'>
193+
<span className='flex-shrink-0 font-mono text-slate-600 text-xs dark:text-slate-400'>
194+
{index}:
195+
</span>
185196
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(item)}</div>
186197
</div>
187198
))}
@@ -196,10 +207,10 @@ function BlockDataDisplay({
196207
if (entries.length === 0) return <span className='text-muted-foreground'>{'{}'}</span>
197208

198209
return (
199-
<div className='space-y-1'>
210+
<div className='space-y-0.5'>
200211
{entries.map(([objKey, objValue]) => (
201-
<div key={objKey} className='flex min-w-0 gap-2'>
202-
<span className='flex-shrink-0 font-medium text-orange-700 dark:text-orange-400'>
212+
<div key={objKey} className='flex min-w-0 gap-1.5'>
213+
<span className='flex-shrink-0 font-medium text-indigo-700 dark:text-indigo-400'>
203214
{objKey}:
204215
</span>
205216
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(objValue, objKey)}</div>
@@ -227,12 +238,12 @@ function BlockDataDisplay({
227238
{transformedData &&
228239
Object.keys(transformedData).filter((key) => key !== 'error' && key !== 'success')
229240
.length > 0 && (
230-
<div className='space-y-1'>
241+
<div className='space-y-0.5'>
231242
{Object.entries(transformedData)
232243
.filter(([key]) => key !== 'error' && key !== 'success')
233244
.map(([key, value]) => (
234-
<div key={key} className='flex gap-2'>
235-
<span className='font-medium text-orange-700 dark:text-orange-400'>{key}:</span>
245+
<div key={key} className='flex gap-1.5'>
246+
<span className='font-medium text-indigo-700 dark:text-indigo-400'>{key}:</span>
236247
{renderValue(value, key)}
237248
</div>
238249
))}
@@ -592,7 +603,9 @@ function TraceSpanItem({
592603
{expanded && (
593604
<div>
594605
{/* Block Input/Output Data - Collapsible */}
595-
{(span.input || span.output) && <CollapsibleInputOutput span={span} spanId={spanId} />}
606+
{(span.input || span.output) && (
607+
<CollapsibleInputOutput span={span} spanId={spanId} depth={depth} />
608+
)}
596609

597610
{/* Children and tool calls */}
598611
{/* Render child spans */}

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -720,21 +720,47 @@ export function Sidebar() {
720720
`[data-workflow-id="${workflowId}"]`
721721
) as HTMLElement
722722
if (activeWorkflow) {
723-
activeWorkflow.scrollIntoView({
724-
block: 'start',
725-
})
726-
727-
// Adjust scroll position to eliminate the small gap at the top
728-
const scrollViewport = scrollContainer.querySelector(
729-
'[data-radix-scroll-area-viewport]'
730-
) as HTMLElement
731-
if (scrollViewport && scrollViewport.scrollTop > 0) {
732-
scrollViewport.scrollTop = Math.max(0, scrollViewport.scrollTop - 8)
723+
// Check if this is a newly created workflow (created within the last 5 seconds)
724+
const currentWorkflow = workflows[workflowId]
725+
const isNewlyCreated =
726+
currentWorkflow &&
727+
currentWorkflow.lastModified instanceof Date &&
728+
Date.now() - currentWorkflow.lastModified.getTime() < 5000 // 5 seconds
729+
730+
if (isNewlyCreated) {
731+
// For newly created workflows, use the original behavior - scroll to top
732+
activeWorkflow.scrollIntoView({
733+
block: 'start',
734+
})
735+
736+
// Adjust scroll position to eliminate the small gap at the top
737+
const scrollViewport = scrollContainer.querySelector(
738+
'[data-radix-scroll-area-viewport]'
739+
) as HTMLElement
740+
if (scrollViewport && scrollViewport.scrollTop > 0) {
741+
scrollViewport.scrollTop = Math.max(0, scrollViewport.scrollTop - 8)
742+
}
743+
} else {
744+
// For existing workflows, check if already visible and scroll minimally
745+
const containerRect = scrollContainer.getBoundingClientRect()
746+
const workflowRect = activeWorkflow.getBoundingClientRect()
747+
748+
// Only scroll if the workflow is not fully visible
749+
const isFullyVisible =
750+
workflowRect.top >= containerRect.top && workflowRect.bottom <= containerRect.bottom
751+
752+
if (!isFullyVisible) {
753+
// Use 'nearest' to scroll minimally - only bring into view, don't force to top
754+
activeWorkflow.scrollIntoView({
755+
block: 'nearest',
756+
behavior: 'smooth',
757+
})
758+
}
733759
}
734760
}
735761
}
736762
}
737-
}, [workflowId, isLoading])
763+
}, [workflowId, isLoading, workflows])
738764

739765
const [showSettings, setShowSettings] = useState(false)
740766
const [showHelp, setShowHelp] = useState(false)

apps/sim/executor/handlers/workflow/workflow-handler.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ describe('WorkflowBlockHandler', () => {
209209
success: true,
210210
childWorkflowName: 'Child Workflow',
211211
result: { data: 'test result' },
212+
childTraceSpans: [],
212213
})
213214
})
214215

@@ -248,6 +249,7 @@ describe('WorkflowBlockHandler', () => {
248249
success: true,
249250
childWorkflowName: 'Child Workflow',
250251
result: { nested: 'data' },
252+
childTraceSpans: [],
251253
})
252254
})
253255
})

apps/sim/executor/handlers/workflow/workflow-handler.ts

Lines changed: 93 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { generateInternalToken } from '@/lib/auth/internal'
22
import { createLogger } from '@/lib/logs/console/logger'
3+
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
34
import { getBaseUrl } from '@/lib/urls/utils'
45
import type { BlockOutput } from '@/blocks/types'
56
import { Executor } from '@/executor'
@@ -104,18 +105,17 @@ export class WorkflowBlockHandler implements BlockHandler {
104105
// Remove current execution from stack after completion
105106
WorkflowBlockHandler.executionStack.delete(executionId)
106107

107-
// Log execution completion
108108
logger.info(`Child workflow ${childWorkflowName} completed in ${Math.round(duration)}ms`)
109109

110-
// Map child workflow output to parent block output
110+
const childTraceSpans = this.captureChildWorkflowLogs(result, childWorkflowName, context)
111111
const mappedResult = this.mapChildOutputToParent(
112112
result,
113113
workflowId,
114114
childWorkflowName,
115-
duration
115+
duration,
116+
childTraceSpans
116117
)
117118

118-
// If the child workflow failed, throw an error to trigger proper error handling in the parent
119119
if ((mappedResult as any).success === false) {
120120
const childError = (mappedResult as any).error || 'Unknown error'
121121
throw new Error(`Error in child workflow "${childWorkflowName}": ${childError}`)
@@ -125,19 +125,13 @@ export class WorkflowBlockHandler implements BlockHandler {
125125
} catch (error: any) {
126126
logger.error(`Error executing child workflow ${workflowId}:`, error)
127127

128-
// Clean up execution stack in case of error
129128
const executionId = `${context.workflowId}_sub_${workflowId}_${block.id}`
130129
WorkflowBlockHandler.executionStack.delete(executionId)
131-
132-
// Get workflow name for error reporting
133130
const { workflows } = useWorkflowRegistry.getState()
134131
const workflowMetadata = workflows[workflowId]
135132
const childWorkflowName = workflowMetadata?.name || workflowId
136133

137-
// Enhance error message with child workflow context
138134
const originalError = error.message || 'Unknown error'
139-
140-
// Check if error message already has child workflow context to avoid duplication
141135
if (originalError.startsWith('Error in child workflow')) {
142136
throw error // Re-throw as-is to avoid duplication
143137
}
@@ -151,12 +145,9 @@ export class WorkflowBlockHandler implements BlockHandler {
151145
*/
152146
private async loadChildWorkflow(workflowId: string) {
153147
try {
154-
// Fetch workflow from API with internal authentication header
155148
const headers: Record<string, string> = {
156149
'Content-Type': 'application/json',
157150
}
158-
159-
// Add internal auth header for server-side calls
160151
if (typeof window === 'undefined') {
161152
const token = await generateInternalToken()
162153
headers.Authorization = `Bearer ${token}`
@@ -182,16 +173,12 @@ export class WorkflowBlockHandler implements BlockHandler {
182173
}
183174

184175
logger.info(`Loaded child workflow: ${workflowData.name} (${workflowId})`)
185-
186-
// Extract the workflow state (API returns normalized data in state field)
187176
const workflowState = workflowData.state
188177

189178
if (!workflowState || !workflowState.blocks) {
190179
logger.error(`Child workflow ${workflowId} has invalid state`)
191180
return null
192181
}
193-
194-
// Use blocks directly since API returns data from normalized tables
195182
const serializedWorkflow = this.serializer.serializeWorkflow(
196183
workflowState.blocks,
197184
workflowState.edges || [],
@@ -222,17 +209,101 @@ export class WorkflowBlockHandler implements BlockHandler {
222209
}
223210

224211
/**
225-
* Maps child workflow output to parent block output format
212+
* Captures and transforms child workflow logs into trace spans
213+
*/
214+
private captureChildWorkflowLogs(
215+
childResult: any,
216+
childWorkflowName: string,
217+
parentContext: ExecutionContext
218+
): any[] {
219+
try {
220+
if (!childResult.logs || !Array.isArray(childResult.logs)) {
221+
return []
222+
}
223+
224+
const { traceSpans } = buildTraceSpans(childResult)
225+
226+
if (!traceSpans || traceSpans.length === 0) {
227+
return []
228+
}
229+
230+
const transformedSpans = traceSpans.map((span: any) => {
231+
return this.transformSpanForChildWorkflow(span, childWorkflowName)
232+
})
233+
234+
return transformedSpans
235+
} catch (error) {
236+
logger.error(`Error capturing child workflow logs for ${childWorkflowName}:`, error)
237+
return []
238+
}
239+
}
240+
241+
/**
242+
* Transforms trace span for child workflow context
243+
*/
244+
private transformSpanForChildWorkflow(span: any, childWorkflowName: string): any {
245+
const transformedSpan = {
246+
...span,
247+
name: this.cleanChildSpanName(span.name, childWorkflowName),
248+
metadata: {
249+
...span.metadata,
250+
isFromChildWorkflow: true,
251+
childWorkflowName,
252+
},
253+
}
254+
255+
if (span.children && Array.isArray(span.children)) {
256+
transformedSpan.children = span.children.map((childSpan: any) =>
257+
this.transformSpanForChildWorkflow(childSpan, childWorkflowName)
258+
)
259+
}
260+
261+
if (span.output?.childTraceSpans) {
262+
transformedSpan.output = {
263+
...transformedSpan.output,
264+
childTraceSpans: span.output.childTraceSpans,
265+
}
266+
}
267+
268+
return transformedSpan
269+
}
270+
271+
/**
272+
* Cleans up child span names for readability
273+
*/
274+
private cleanChildSpanName(spanName: string, childWorkflowName: string): string {
275+
if (spanName.includes(`${childWorkflowName}:`)) {
276+
const cleanName = spanName.replace(`${childWorkflowName}:`, '').trim()
277+
278+
if (cleanName === 'Workflow Execution') {
279+
return `${childWorkflowName} workflow`
280+
}
281+
282+
if (cleanName.startsWith('Agent ')) {
283+
return `${cleanName}`
284+
}
285+
286+
return `${cleanName}`
287+
}
288+
289+
if (spanName === 'Workflow Execution') {
290+
return `${childWorkflowName} workflow`
291+
}
292+
293+
return `${spanName}`
294+
}
295+
296+
/**
297+
* Maps child workflow output to parent block output
226298
*/
227299
private mapChildOutputToParent(
228300
childResult: any,
229301
childWorkflowId: string,
230302
childWorkflowName: string,
231-
duration: number
303+
duration: number,
304+
childTraceSpans?: any[]
232305
): BlockOutput {
233306
const success = childResult.success !== false
234-
235-
// If child workflow failed, return minimal output
236307
if (!success) {
237308
logger.warn(`Child workflow ${childWorkflowName} failed`)
238309
return {
@@ -241,18 +312,15 @@ export class WorkflowBlockHandler implements BlockHandler {
241312
error: childResult.error || 'Child workflow execution failed',
242313
} as Record<string, any>
243314
}
244-
245-
// Extract the actual result content from the flattened structure
246315
let result = childResult
247316
if (childResult?.output) {
248317
result = childResult.output
249318
}
250-
251-
// Return a properly structured response with all required fields
252319
return {
253320
success: true,
254321
childWorkflowName,
255322
result,
323+
childTraceSpans: childTraceSpans || [],
256324
} as Record<string, any>
257325
}
258326
}

0 commit comments

Comments
 (0)