Skip to content

Commit 57f0837

Browse files
fix(child-workflow-error-spans): pass trace-spans accurately in block logs (#3054)
* fix(child-workflow): must bypass hiddenFromDisplay config * fix passing of spans to be in block log * keep fallback for backwards compat * fix error message formatting * clean up
1 parent 5c02d46 commit 57f0837

File tree

9 files changed

+180
-164
lines changed

9 files changed

+180
-164
lines changed

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

Lines changed: 4 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -57,40 +57,6 @@ function useSetToggle() {
5757
)
5858
}
5959

60-
/**
61-
* Generates a unique key for a trace span
62-
*/
63-
function getSpanKey(span: TraceSpan): string {
64-
if (span.id) {
65-
return span.id
66-
}
67-
const name = span.name || 'span'
68-
const start = span.startTime || 'unknown-start'
69-
const end = span.endTime || 'unknown-end'
70-
return `${name}|${start}|${end}`
71-
}
72-
73-
/**
74-
* Merges multiple arrays of trace span children, deduplicating by span key
75-
*/
76-
function mergeTraceSpanChildren(...groups: TraceSpan[][]): TraceSpan[] {
77-
const merged: TraceSpan[] = []
78-
const seen = new Set<string>()
79-
80-
groups.forEach((group) => {
81-
group.forEach((child) => {
82-
const key = getSpanKey(child)
83-
if (seen.has(key)) {
84-
return
85-
}
86-
seen.add(key)
87-
merged.push(child)
88-
})
89-
})
90-
91-
return merged
92-
}
93-
9460
/**
9561
* Parses a time value to milliseconds
9662
*/
@@ -116,34 +82,16 @@ function hasErrorInTree(span: TraceSpan): boolean {
11682

11783
/**
11884
* Normalizes and sorts trace spans recursively.
119-
* Merges children from both span.children and span.output.childTraceSpans,
120-
* deduplicates them, and sorts by start time.
85+
* Deduplicates children and sorts by start time.
12186
*/
12287
function normalizeAndSortSpans(spans: TraceSpan[]): TraceSpan[] {
12388
return spans
12489
.map((span) => {
12590
const enrichedSpan: TraceSpan = { ...span }
12691

127-
// Clean output by removing childTraceSpans after extracting
128-
if (enrichedSpan.output && typeof enrichedSpan.output === 'object') {
129-
enrichedSpan.output = { ...enrichedSpan.output }
130-
if ('childTraceSpans' in enrichedSpan.output) {
131-
const { childTraceSpans, ...cleanOutput } = enrichedSpan.output as {
132-
childTraceSpans?: TraceSpan[]
133-
} & Record<string, unknown>
134-
enrichedSpan.output = cleanOutput
135-
}
136-
}
137-
138-
// Merge and deduplicate children from both sources
139-
const directChildren = Array.isArray(span.children) ? span.children : []
140-
const outputChildren = Array.isArray(span.output?.childTraceSpans)
141-
? (span.output!.childTraceSpans as TraceSpan[])
142-
: []
143-
144-
const mergedChildren = mergeTraceSpanChildren(directChildren, outputChildren)
145-
enrichedSpan.children =
146-
mergedChildren.length > 0 ? normalizeAndSortSpans(mergedChildren) : undefined
92+
// Process and deduplicate children
93+
const children = Array.isArray(span.children) ? span.children : []
94+
enrichedSpan.children = children.length > 0 ? normalizeAndSortSpans(children) : undefined
14795

14896
return enrichedSpan
14997
})

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import { ScrollArea } from '@/components/ui/scroll-area'
1919
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
2020
import { cn } from '@/lib/core/utils/cn'
21+
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
2122
import {
2223
ExecutionSnapshot,
2324
FileCards,
@@ -274,16 +275,13 @@ export const LogDetails = memo(function LogDetails({
274275
return isWorkflowExecutionLog && log?.cost
275276
}, [log, isWorkflowExecutionLog])
276277

277-
// Extract and clean the workflow final output (remove childTraceSpans for cleaner display)
278+
// Extract and clean the workflow final output (recursively remove hidden keys for cleaner display)
278279
const workflowOutput = useMemo(() => {
279280
const executionData = log?.executionData as
280281
| { finalOutput?: Record<string, unknown> }
281282
| undefined
282283
if (!executionData?.finalOutput) return null
283-
const { childTraceSpans, ...cleanOutput } = executionData.finalOutput as {
284-
childTraceSpans?: unknown
285-
} & Record<string, unknown>
286-
return cleanOutput
284+
return filterHiddenOutputKeys(executionData.finalOutput) as Record<string, unknown>
287285
}, [log?.executionData])
288286

289287
useEffect(() => {

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ interface WorkflowStackEntry {
3939

4040
/**
4141
* Extracts child trace spans from a workflow block's execution data.
42-
* Checks both the `children` property (where trace span processing moves them)
43-
* and the legacy `output.childTraceSpans` for compatibility.
42+
* Checks `children` property (where trace-spans processing puts them),
43+
* with fallback to `output.childTraceSpans` for old stored logs.
4444
*/
4545
function extractChildTraceSpans(blockExecution: BlockExecutionData | undefined): TraceSpan[] {
4646
if (!blockExecution) return []
@@ -49,6 +49,7 @@ function extractChildTraceSpans(blockExecution: BlockExecutionData | undefined):
4949
return blockExecution.children
5050
}
5151

52+
// Backward compat: old stored logs may have childTraceSpans in output
5253
if (blockExecution.output && typeof blockExecution.output === 'object') {
5354
const output = blockExecution.output as Record<string, unknown>
5455
if (Array.isArray(output.childTraceSpans)) {

apps/sim/executor/execution/block-executor.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@ export class BlockExecutor {
152152
blockLog.durationMs = duration
153153
blockLog.success = true
154154
blockLog.output = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { block })
155+
if (normalizedOutput.childTraceSpans && Array.isArray(normalizedOutput.childTraceSpans)) {
156+
blockLog.childTraceSpans = normalizedOutput.childTraceSpans
157+
}
155158
}
156159

157160
this.state.setBlockOutput(node.id, normalizedOutput, duration)
@@ -245,6 +248,10 @@ export class BlockExecutor {
245248
blockLog.error = errorMessage
246249
blockLog.input = this.sanitizeInputsForLog(input)
247250
blockLog.output = filterOutputForLog(block.metadata?.id || '', errorOutput, { block })
251+
252+
if (errorOutput.childTraceSpans && Array.isArray(errorOutput.childTraceSpans)) {
253+
blockLog.childTraceSpans = errorOutput.childTraceSpans
254+
}
248255
}
249256

250257
logger.error(

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ describe('WorkflowBlockHandler', () => {
118118
}
119119

120120
await expect(handler.execute(deepContext, mockBlock, inputs)).rejects.toThrow(
121-
'Error in child workflow "child-workflow-id": Maximum workflow nesting depth of 10 exceeded'
121+
'"child-workflow-id" failed: Maximum workflow nesting depth of 10 exceeded'
122122
)
123123
})
124124

@@ -132,7 +132,7 @@ describe('WorkflowBlockHandler', () => {
132132
})
133133

134134
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
135-
'Error in child workflow "non-existent-workflow": Child workflow non-existent-workflow not found'
135+
'"non-existent-workflow" failed: Child workflow non-existent-workflow not found'
136136
)
137137
})
138138

@@ -142,7 +142,7 @@ describe('WorkflowBlockHandler', () => {
142142
mockFetch.mockRejectedValueOnce(new Error('Network error'))
143143

144144
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
145-
'Error in child workflow "child-workflow-id": Network error'
145+
'"child-workflow-id" failed: Network error'
146146
)
147147
})
148148
})
@@ -212,7 +212,7 @@ describe('WorkflowBlockHandler', () => {
212212

213213
expect(() =>
214214
(handler as any).mapChildOutputToParent(childResult, 'child-id', 'Child Workflow', 100)
215-
).toThrow('Error in child workflow "Child Workflow": Child workflow failed')
215+
).toThrow('"Child Workflow" failed: Child workflow failed')
216216

217217
try {
218218
;(handler as any).mapChildOutputToParent(childResult, 'child-id', 'Child Workflow', 100)

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

Lines changed: 78 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ export class WorkflowBlockHandler implements BlockHandler {
5252
throw new Error('No workflow selected for execution')
5353
}
5454

55+
// Initialize with registry name, will be updated with loaded workflow name
56+
const { workflows } = useWorkflowRegistry.getState()
57+
const workflowMetadata = workflows[workflowId]
58+
let childWorkflowName = workflowMetadata?.name || workflowId
59+
5560
try {
5661
const currentDepth = (ctx.workflowId?.split('_sub_').length || 1) - 1
5762
if (currentDepth >= DEFAULTS.MAX_WORKFLOW_DEPTH) {
@@ -75,9 +80,8 @@ export class WorkflowBlockHandler implements BlockHandler {
7580
throw new Error(`Child workflow ${workflowId} not found`)
7681
}
7782

78-
const { workflows } = useWorkflowRegistry.getState()
79-
const workflowMetadata = workflows[workflowId]
80-
const childWorkflowName = workflowMetadata?.name || childWorkflow.name || 'Unknown Workflow'
83+
// Update with loaded workflow name (more reliable than registry)
84+
childWorkflowName = workflowMetadata?.name || childWorkflow.name || 'Unknown Workflow'
8185

8286
logger.info(
8387
`Executing child workflow: ${childWorkflowName} (${workflowId}) at depth ${currentDepth}`
@@ -142,11 +146,6 @@ export class WorkflowBlockHandler implements BlockHandler {
142146
} catch (error: unknown) {
143147
logger.error(`Error executing child workflow ${workflowId}:`, error)
144148

145-
const { workflows } = useWorkflowRegistry.getState()
146-
const workflowMetadata = workflows[workflowId]
147-
const childWorkflowName = workflowMetadata?.name || workflowId
148-
149-
const originalError = error instanceof Error ? error.message : 'Unknown error'
150149
let childTraceSpans: WorkflowTraceSpan[] = []
151150
let executionResult: ExecutionResult | undefined
152151

@@ -165,8 +164,11 @@ export class WorkflowBlockHandler implements BlockHandler {
165164
childTraceSpans = error.childTraceSpans
166165
}
167166

167+
// Build a cleaner error message for nested workflow errors
168+
const errorMessage = this.buildNestedWorkflowErrorMessage(childWorkflowName, error)
169+
168170
throw new ChildWorkflowError({
169-
message: `Error in child workflow "${childWorkflowName}": ${originalError}`,
171+
message: errorMessage,
170172
childWorkflowName,
171173
childTraceSpans,
172174
executionResult,
@@ -175,6 +177,72 @@ export class WorkflowBlockHandler implements BlockHandler {
175177
}
176178
}
177179

180+
/**
181+
* Builds a cleaner error message for nested workflow errors.
182+
* Parses nested error messages to extract workflow chain and root error.
183+
*/
184+
private buildNestedWorkflowErrorMessage(childWorkflowName: string, error: unknown): string {
185+
const originalError = error instanceof Error ? error.message : 'Unknown error'
186+
187+
// Extract any nested workflow names from the error message
188+
const { chain, rootError } = this.parseNestedWorkflowError(originalError)
189+
190+
// Add current workflow to the beginning of the chain
191+
chain.unshift(childWorkflowName)
192+
193+
// If we have a chain (nested workflows), format nicely
194+
if (chain.length > 1) {
195+
return `Workflow chain: ${chain.join(' → ')} | ${rootError}`
196+
}
197+
198+
// Single workflow failure
199+
return `"${childWorkflowName}" failed: ${rootError}`
200+
}
201+
202+
/**
203+
* Parses a potentially nested workflow error message to extract:
204+
* - The chain of workflow names
205+
* - The actual root error message (preserving the block prefix for the failing block)
206+
*
207+
* Handles formats like:
208+
* - "workflow-name" failed: error
209+
* - [block_type] Block Name: "workflow-name" failed: error
210+
* - Workflow chain: A → B | error
211+
*/
212+
private parseNestedWorkflowError(message: string): { chain: string[]; rootError: string } {
213+
const chain: string[] = []
214+
const remaining = message
215+
216+
// First, check if it's already in chain format
217+
const chainMatch = remaining.match(/^Workflow chain: (.+?) \| (.+)$/)
218+
if (chainMatch) {
219+
const chainPart = chainMatch[1]
220+
const errorPart = chainMatch[2]
221+
chain.push(...chainPart.split(' → ').map((s) => s.trim()))
222+
return { chain, rootError: errorPart }
223+
}
224+
225+
// Extract workflow names from patterns like:
226+
// - "workflow-name" failed:
227+
// - [block_type] Block Name: "workflow-name" failed:
228+
const workflowPattern = /(?:\[[^\]]+\]\s*[^:]+:\s*)?"([^"]+)"\s*failed:\s*/g
229+
let match: RegExpExecArray | null
230+
let lastIndex = 0
231+
232+
match = workflowPattern.exec(remaining)
233+
while (match !== null) {
234+
chain.push(match[1])
235+
lastIndex = match.index + match[0].length
236+
match = workflowPattern.exec(remaining)
237+
}
238+
239+
// The root error is everything after the last match
240+
// Keep the block prefix (e.g., [function] Function 1:) so we know which block failed
241+
const rootError = lastIndex > 0 ? remaining.slice(lastIndex) : remaining
242+
243+
return { chain, rootError: rootError.trim() || 'Unknown error' }
244+
}
245+
178246
private async loadChildWorkflow(workflowId: string) {
179247
const headers = await buildAuthHeaders()
180248
const url = buildAPIUrl(`/api/workflows/${workflowId}`)
@@ -444,7 +512,7 @@ export class WorkflowBlockHandler implements BlockHandler {
444512
if (!success) {
445513
logger.warn(`Child workflow ${childWorkflowName} failed`)
446514
throw new ChildWorkflowError({
447-
message: `Error in child workflow "${childWorkflowName}": ${childResult.error || 'Child workflow execution failed'}`,
515+
message: `"${childWorkflowName}" failed: ${childResult.error || 'Child workflow execution failed'}`,
448516
childWorkflowName,
449517
childTraceSpans: childTraceSpans || [],
450518
})

apps/sim/executor/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@ export interface BlockLog {
114114
loopId?: string
115115
parallelId?: string
116116
iterationIndex?: number
117+
/**
118+
* Child workflow trace spans for nested workflow execution.
119+
* Stored separately from output to keep output clean for display
120+
* while preserving data for trace-spans processing.
121+
*/
122+
childTraceSpans?: TraceSpan[]
117123
}
118124

119125
export interface ExecutionMetadata {

apps/sim/executor/utils/output-filter.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
12
import { getBlock } from '@/blocks'
23
import { isHiddenFromDisplay } from '@/blocks/types'
34
import { isTriggerBehavior, isTriggerInternalKey } from '@/executor/constants'
@@ -7,6 +8,7 @@ import type { SerializedBlock } from '@/serializer/types'
78
/**
89
* Filters block output for logging/display purposes.
910
* Removes internal fields and fields marked with hiddenFromDisplay.
11+
* Also recursively filters globally hidden keys from nested objects.
1012
*
1113
* @param blockType - The block type string (e.g., 'human_in_the_loop', 'workflow')
1214
* @param output - The raw block output to filter
@@ -44,7 +46,8 @@ export function filterOutputForLog(
4446
continue
4547
}
4648

47-
filtered[key] = value
49+
// Recursively filter globally hidden keys from nested objects
50+
filtered[key] = filterHiddenOutputKeys(value)
4851
}
4952

5053
return filtered

0 commit comments

Comments
 (0)