diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/trace-spans/trace-spans-display.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/trace-spans/trace-spans-display.tsx index 36a66245e5e..0d3361816b9 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/trace-spans/trace-spans-display.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/trace-spans/trace-spans-display.tsx @@ -82,14 +82,21 @@ function transformBlockData(data: any, blockType: string, isInput: boolean) { interface CollapsibleInputOutputProps { span: TraceSpan spanId: string + depth: number } -function CollapsibleInputOutput({ span, spanId }: CollapsibleInputOutputProps) { +function CollapsibleInputOutput({ span, spanId, depth }: CollapsibleInputOutputProps) { const [inputExpanded, setInputExpanded] = useState(false) const [outputExpanded, setOutputExpanded] = useState(false) + // Calculate the left margin based on depth to match the parent span's indentation + const leftMargin = depth * 16 + 8 + 24 // Base depth indentation + icon width + extra padding + return ( -
+
{/* Input Data - Collapsible */} {span.input && (
@@ -162,26 +169,30 @@ function BlockDataDisplay({ if (value === undefined) return undefined if (typeof value === 'string') { - return "{value}" + return "{value}" } if (typeof value === 'number') { - return {value} + return {value} } if (typeof value === 'boolean') { - return {value.toString()} + return ( + {value.toString()} + ) } if (Array.isArray(value)) { if (value.length === 0) return [] return ( -
+
[ -
+
{value.map((item, index) => ( -
- {index}: +
+ + {index}: +
{renderValue(item)}
))} @@ -196,10 +207,10 @@ function BlockDataDisplay({ if (entries.length === 0) return {'{}'} return ( -
+
{entries.map(([objKey, objValue]) => ( -
- +
+ {objKey}:
{renderValue(objValue, objKey)}
@@ -227,12 +238,12 @@ function BlockDataDisplay({ {transformedData && Object.keys(transformedData).filter((key) => key !== 'error' && key !== 'success') .length > 0 && ( -
+
{Object.entries(transformedData) .filter(([key]) => key !== 'error' && key !== 'success') .map(([key, value]) => ( -
- {key}: +
+ {key}: {renderValue(value, key)}
))} @@ -592,7 +603,9 @@ function TraceSpanItem({ {expanded && (
{/* Block Input/Output Data - Collapsible */} - {(span.input || span.output) && } + {(span.input || span.output) && ( + + )} {/* Children and tool calls */} {/* Render child spans */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 7eff8464613..89b58db560d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -720,21 +720,47 @@ export function Sidebar() { `[data-workflow-id="${workflowId}"]` ) as HTMLElement if (activeWorkflow) { - activeWorkflow.scrollIntoView({ - block: 'start', - }) - - // Adjust scroll position to eliminate the small gap at the top - const scrollViewport = scrollContainer.querySelector( - '[data-radix-scroll-area-viewport]' - ) as HTMLElement - if (scrollViewport && scrollViewport.scrollTop > 0) { - scrollViewport.scrollTop = Math.max(0, scrollViewport.scrollTop - 8) + // Check if this is a newly created workflow (created within the last 5 seconds) + const currentWorkflow = workflows[workflowId] + const isNewlyCreated = + currentWorkflow && + currentWorkflow.lastModified instanceof Date && + Date.now() - currentWorkflow.lastModified.getTime() < 5000 // 5 seconds + + if (isNewlyCreated) { + // For newly created workflows, use the original behavior - scroll to top + activeWorkflow.scrollIntoView({ + block: 'start', + }) + + // Adjust scroll position to eliminate the small gap at the top + const scrollViewport = scrollContainer.querySelector( + '[data-radix-scroll-area-viewport]' + ) as HTMLElement + if (scrollViewport && scrollViewport.scrollTop > 0) { + scrollViewport.scrollTop = Math.max(0, scrollViewport.scrollTop - 8) + } + } else { + // For existing workflows, check if already visible and scroll minimally + const containerRect = scrollContainer.getBoundingClientRect() + const workflowRect = activeWorkflow.getBoundingClientRect() + + // Only scroll if the workflow is not fully visible + const isFullyVisible = + workflowRect.top >= containerRect.top && workflowRect.bottom <= containerRect.bottom + + if (!isFullyVisible) { + // Use 'nearest' to scroll minimally - only bring into view, don't force to top + activeWorkflow.scrollIntoView({ + block: 'nearest', + behavior: 'smooth', + }) + } } } } } - }, [workflowId, isLoading]) + }, [workflowId, isLoading, workflows]) const [showSettings, setShowSettings] = useState(false) const [showHelp, setShowHelp] = useState(false) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index e53dc8271f2..d6df0cd77d6 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -209,6 +209,7 @@ describe('WorkflowBlockHandler', () => { success: true, childWorkflowName: 'Child Workflow', result: { data: 'test result' }, + childTraceSpans: [], }) }) @@ -248,6 +249,7 @@ describe('WorkflowBlockHandler', () => { success: true, childWorkflowName: 'Child Workflow', result: { nested: 'data' }, + childTraceSpans: [], }) }) }) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 0291cc26076..9467a5740c0 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -1,5 +1,6 @@ import { generateInternalToken } from '@/lib/auth/internal' import { createLogger } from '@/lib/logs/console/logger' +import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { getBaseUrl } from '@/lib/urls/utils' import type { BlockOutput } from '@/blocks/types' import { Executor } from '@/executor' @@ -104,18 +105,17 @@ export class WorkflowBlockHandler implements BlockHandler { // Remove current execution from stack after completion WorkflowBlockHandler.executionStack.delete(executionId) - // Log execution completion logger.info(`Child workflow ${childWorkflowName} completed in ${Math.round(duration)}ms`) - // Map child workflow output to parent block output + const childTraceSpans = this.captureChildWorkflowLogs(result, childWorkflowName, context) const mappedResult = this.mapChildOutputToParent( result, workflowId, childWorkflowName, - duration + duration, + childTraceSpans ) - // If the child workflow failed, throw an error to trigger proper error handling in the parent if ((mappedResult as any).success === false) { const childError = (mappedResult as any).error || 'Unknown error' throw new Error(`Error in child workflow "${childWorkflowName}": ${childError}`) @@ -125,19 +125,13 @@ export class WorkflowBlockHandler implements BlockHandler { } catch (error: any) { logger.error(`Error executing child workflow ${workflowId}:`, error) - // Clean up execution stack in case of error const executionId = `${context.workflowId}_sub_${workflowId}_${block.id}` WorkflowBlockHandler.executionStack.delete(executionId) - - // Get workflow name for error reporting const { workflows } = useWorkflowRegistry.getState() const workflowMetadata = workflows[workflowId] const childWorkflowName = workflowMetadata?.name || workflowId - // Enhance error message with child workflow context const originalError = error.message || 'Unknown error' - - // Check if error message already has child workflow context to avoid duplication if (originalError.startsWith('Error in child workflow')) { throw error // Re-throw as-is to avoid duplication } @@ -151,12 +145,9 @@ export class WorkflowBlockHandler implements BlockHandler { */ private async loadChildWorkflow(workflowId: string) { try { - // Fetch workflow from API with internal authentication header const headers: Record = { 'Content-Type': 'application/json', } - - // Add internal auth header for server-side calls if (typeof window === 'undefined') { const token = await generateInternalToken() headers.Authorization = `Bearer ${token}` @@ -182,16 +173,12 @@ export class WorkflowBlockHandler implements BlockHandler { } logger.info(`Loaded child workflow: ${workflowData.name} (${workflowId})`) - - // Extract the workflow state (API returns normalized data in state field) const workflowState = workflowData.state if (!workflowState || !workflowState.blocks) { logger.error(`Child workflow ${workflowId} has invalid state`) return null } - - // Use blocks directly since API returns data from normalized tables const serializedWorkflow = this.serializer.serializeWorkflow( workflowState.blocks, workflowState.edges || [], @@ -222,17 +209,101 @@ export class WorkflowBlockHandler implements BlockHandler { } /** - * Maps child workflow output to parent block output format + * Captures and transforms child workflow logs into trace spans + */ + private captureChildWorkflowLogs( + childResult: any, + childWorkflowName: string, + parentContext: ExecutionContext + ): any[] { + try { + if (!childResult.logs || !Array.isArray(childResult.logs)) { + return [] + } + + const { traceSpans } = buildTraceSpans(childResult) + + if (!traceSpans || traceSpans.length === 0) { + return [] + } + + const transformedSpans = traceSpans.map((span: any) => { + return this.transformSpanForChildWorkflow(span, childWorkflowName) + }) + + return transformedSpans + } catch (error) { + logger.error(`Error capturing child workflow logs for ${childWorkflowName}:`, error) + return [] + } + } + + /** + * Transforms trace span for child workflow context + */ + private transformSpanForChildWorkflow(span: any, childWorkflowName: string): any { + const transformedSpan = { + ...span, + name: this.cleanChildSpanName(span.name, childWorkflowName), + metadata: { + ...span.metadata, + isFromChildWorkflow: true, + childWorkflowName, + }, + } + + if (span.children && Array.isArray(span.children)) { + transformedSpan.children = span.children.map((childSpan: any) => + this.transformSpanForChildWorkflow(childSpan, childWorkflowName) + ) + } + + if (span.output?.childTraceSpans) { + transformedSpan.output = { + ...transformedSpan.output, + childTraceSpans: span.output.childTraceSpans, + } + } + + return transformedSpan + } + + /** + * Cleans up child span names for readability + */ + private cleanChildSpanName(spanName: string, childWorkflowName: string): string { + if (spanName.includes(`${childWorkflowName}:`)) { + const cleanName = spanName.replace(`${childWorkflowName}:`, '').trim() + + if (cleanName === 'Workflow Execution') { + return `${childWorkflowName} workflow` + } + + if (cleanName.startsWith('Agent ')) { + return `${cleanName}` + } + + return `${cleanName}` + } + + if (spanName === 'Workflow Execution') { + return `${childWorkflowName} workflow` + } + + return `${spanName}` + } + + /** + * Maps child workflow output to parent block output */ private mapChildOutputToParent( childResult: any, childWorkflowId: string, childWorkflowName: string, - duration: number + duration: number, + childTraceSpans?: any[] ): BlockOutput { const success = childResult.success !== false - - // If child workflow failed, return minimal output if (!success) { logger.warn(`Child workflow ${childWorkflowName} failed`) return { @@ -241,18 +312,15 @@ export class WorkflowBlockHandler implements BlockHandler { error: childResult.error || 'Child workflow execution failed', } as Record } - - // Extract the actual result content from the flattened structure let result = childResult if (childResult?.output) { result = childResult.output } - - // Return a properly structured response with all required fields return { success: true, childWorkflowName, result, + childTraceSpans: childTraceSpans || [], } as Record } } diff --git a/apps/sim/executor/index.ts b/apps/sim/executor/index.ts index b7d752b9dcc..37eba4102c3 100644 --- a/apps/sim/executor/index.ts +++ b/apps/sim/executor/index.ts @@ -1,5 +1,6 @@ import { BlockPathCalculator } from '@/lib/block-path-calculator' import { createLogger } from '@/lib/logs/console/logger' +import type { TraceSpan } from '@/lib/logs/types' import type { BlockOutput } from '@/blocks/types' import { BlockType } from '@/executor/consts' import { @@ -1510,6 +1511,9 @@ export class Executor { blockLog.durationMs = Math.round(executionTime) blockLog.endedAt = new Date().toISOString() + // Handle child workflow logs integration + this.integrateChildWorkflowLogs(block, output) + context.blockLogs.push(blockLog) // Skip console logging for infrastructure blocks like loops and parallels @@ -1617,6 +1621,9 @@ export class Executor { blockLog.durationMs = Math.round(executionTime) blockLog.endedAt = new Date().toISOString() + // Handle child workflow logs integration + this.integrateChildWorkflowLogs(block, output) + context.blockLogs.push(blockLog) // Skip console logging for infrastructure blocks like loops and parallels @@ -2003,4 +2010,22 @@ export class Executor { context.blockLogs.push(starterBlockLog) } } + + /** + * Preserves child workflow trace spans for proper nesting + */ + private integrateChildWorkflowLogs(block: SerializedBlock, output: NormalizedBlockOutput): void { + if (block.metadata?.id !== BlockType.WORKFLOW) { + return + } + + if (!output || typeof output !== 'object' || !output.childTraceSpans) { + return + } + + const childTraceSpans = output.childTraceSpans as TraceSpan[] + if (!Array.isArray(childTraceSpans) || childTraceSpans.length === 0) { + return + } + } } diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts index 04553f48f1b..63a97511306 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts @@ -90,29 +90,44 @@ export function buildTraceSpans(result: ExecutionResult): { // Always add cost, token, and model information if available (regardless of provider timing) if (log.output?.cost) { ;(span as any).cost = log.output.cost - logger.debug(`Added cost to span ${span.id}`, { - blockId: log.blockId, - blockType: log.blockType, - cost: log.output.cost, - }) } if (log.output?.tokens) { ;(span as any).tokens = log.output.tokens - logger.debug(`Added tokens to span ${span.id}`, { - blockId: log.blockId, - blockType: log.blockType, - tokens: log.output.tokens, - }) } if (log.output?.model) { ;(span as any).model = log.output.model - logger.debug(`Added model to span ${span.id}`, { - blockId: log.blockId, - blockType: log.blockType, - model: log.output.model, + } + + // Handle child workflow spans for workflow blocks + if ( + log.blockType === 'workflow' && + log.output?.childTraceSpans && + Array.isArray(log.output.childTraceSpans) + ) { + // Convert child trace spans to be direct children of this workflow block span + const childTraceSpans = log.output.childTraceSpans as TraceSpan[] + + // Process child workflow spans and add them as children + const flatChildSpans: TraceSpan[] = [] + childTraceSpans.forEach((childSpan) => { + // Skip the synthetic workflow span wrapper - we only want the actual block executions + if (childSpan.type === 'workflow' && childSpan.name === 'Workflow Execution') { + // Add its children directly, skipping the synthetic wrapper + if (childSpan.children && Array.isArray(childSpan.children)) { + flatChildSpans.push(...childSpan.children) + } + } else { + // This is a regular span, add it directly + // But first, ensure nested workflow blocks in this span are also processed + const processedSpan = ensureNestedWorkflowsProcessed(childSpan) + flatChildSpans.push(processedSpan) + } }) + + // Add the child spans as children of this workflow block + span.children = flatChildSpans } // Enhanced approach: Use timeSegments for sequential flow if available @@ -163,20 +178,6 @@ export function buildTraceSpans(result: ExecutionResult): { status: 'success', } }) - - logger.debug( - `Created ${span.children?.length || 0} sequential segments for span ${span.id}`, - { - blockId: log.blockId, - blockType: log.blockType, - segments: - span.children?.map((child) => ({ - name: child.name, - type: child.type, - duration: child.duration, - })) || [], - } - ) } else { // Fallback: Extract tool calls using the original approach for backwards compatibility // Tool calls handling for different formats: @@ -237,12 +238,6 @@ export function buildTraceSpans(result: ExecutionResult): { } }) .filter(Boolean) // Remove any null entries from failed processing - - logger.debug(`Added ${span.toolCalls?.length || 0} tool calls to span ${span.id}`, { - blockId: log.blockId, - blockType: log.blockType, - toolCallNames: span.toolCalls?.map((tc) => tc.name) || [], - }) } } @@ -384,6 +379,45 @@ export function buildTraceSpans(result: ExecutionResult): { return { traceSpans: rootSpans, totalDuration } } +// Helper function to recursively process nested workflow blocks in trace spans +function ensureNestedWorkflowsProcessed(span: TraceSpan): TraceSpan { + // Create a copy to avoid mutating the original + const processedSpan = { ...span } + + // If this is a workflow block and it has childTraceSpans in its output, process them + if ( + span.type === 'workflow' && + span.output?.childTraceSpans && + Array.isArray(span.output.childTraceSpans) + ) { + const childTraceSpans = span.output.childTraceSpans as TraceSpan[] + const nestedChildren: TraceSpan[] = [] + + childTraceSpans.forEach((childSpan) => { + // Skip synthetic workflow wrappers and get the actual blocks + if (childSpan.type === 'workflow' && childSpan.name === 'Workflow Execution') { + if (childSpan.children && Array.isArray(childSpan.children)) { + // Recursively process each child to handle deeper nesting + childSpan.children.forEach((grandchildSpan) => { + nestedChildren.push(ensureNestedWorkflowsProcessed(grandchildSpan)) + }) + } + } else { + // Regular span, recursively process it for potential deeper nesting + nestedChildren.push(ensureNestedWorkflowsProcessed(childSpan)) + } + }) + + // Set the processed children on this workflow block + processedSpan.children = nestedChildren + } else if (span.children && Array.isArray(span.children)) { + // Recursively process regular children too + processedSpan.children = span.children.map((child) => ensureNestedWorkflowsProcessed(child)) + } + + return processedSpan +} + export function stripCustomToolPrefix(name: string) { return name.startsWith('custom_') ? name.replace('custom_', '') : name }