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
This commit is contained in:
Waleed Latif
2025-08-19 17:57:37 -07:00
committed by Siddharth Ganesan
parent 9ad36c0e34
commit 79e932fed9
6 changed files with 253 additions and 85 deletions

View File

@@ -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 (
<div className='mt-2 mr-4 mb-4 ml-8 space-y-3 overflow-hidden'>
<div
className='mt-2 mr-4 mb-4 space-y-3 overflow-hidden'
style={{ marginLeft: `${leftMargin}px` }}
>
{/* Input Data - Collapsible */}
{span.input && (
<div>
@@ -162,26 +169,30 @@ function BlockDataDisplay({
if (value === undefined) return <span className='text-muted-foreground italic'>undefined</span>
if (typeof value === 'string') {
return <span className='break-all text-green-700 dark:text-green-400'>"{value}"</span>
return <span className='break-all text-emerald-700 dark:text-emerald-400'>"{value}"</span>
}
if (typeof value === 'number') {
return <span className='text-blue-700 dark:text-blue-400'>{value}</span>
return <span className='font-mono text-blue-700 dark:text-blue-400'>{value}</span>
}
if (typeof value === 'boolean') {
return <span className='text-purple-700 dark:text-purple-400'>{value.toString()}</span>
return (
<span className='font-mono text-amber-700 dark:text-amber-400'>{value.toString()}</span>
)
}
if (Array.isArray(value)) {
if (value.length === 0) return <span className='text-muted-foreground'>[]</span>
return (
<div className='space-y-1'>
<div className='space-y-0.5'>
<span className='text-muted-foreground'>[</span>
<div className='ml-4 space-y-1'>
<div className='ml-2 space-y-0.5'>
{value.map((item, index) => (
<div key={index} className='flex min-w-0 gap-2'>
<span className='flex-shrink-0 text-muted-foreground text-xs'>{index}:</span>
<div key={index} className='flex min-w-0 gap-1.5'>
<span className='flex-shrink-0 font-mono text-slate-600 text-xs dark:text-slate-400'>
{index}:
</span>
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(item)}</div>
</div>
))}
@@ -196,10 +207,10 @@ function BlockDataDisplay({
if (entries.length === 0) return <span className='text-muted-foreground'>{'{}'}</span>
return (
<div className='space-y-1'>
<div className='space-y-0.5'>
{entries.map(([objKey, objValue]) => (
<div key={objKey} className='flex min-w-0 gap-2'>
<span className='flex-shrink-0 font-medium text-orange-700 dark:text-orange-400'>
<div key={objKey} className='flex min-w-0 gap-1.5'>
<span className='flex-shrink-0 font-medium text-indigo-700 dark:text-indigo-400'>
{objKey}:
</span>
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(objValue, objKey)}</div>
@@ -227,12 +238,12 @@ function BlockDataDisplay({
{transformedData &&
Object.keys(transformedData).filter((key) => key !== 'error' && key !== 'success')
.length > 0 && (
<div className='space-y-1'>
<div className='space-y-0.5'>
{Object.entries(transformedData)
.filter(([key]) => key !== 'error' && key !== 'success')
.map(([key, value]) => (
<div key={key} className='flex gap-2'>
<span className='font-medium text-orange-700 dark:text-orange-400'>{key}:</span>
<div key={key} className='flex gap-1.5'>
<span className='font-medium text-indigo-700 dark:text-indigo-400'>{key}:</span>
{renderValue(value, key)}
</div>
))}
@@ -592,7 +603,9 @@ function TraceSpanItem({
{expanded && (
<div>
{/* Block Input/Output Data - Collapsible */}
{(span.input || span.output) && <CollapsibleInputOutput span={span} spanId={spanId} />}
{(span.input || span.output) && (
<CollapsibleInputOutput span={span} spanId={spanId} depth={depth} />
)}
{/* Children and tool calls */}
{/* Render child spans */}

View File

@@ -720,21 +720,47 @@ export function Sidebar() {
`[data-workflow-id="${workflowId}"]`
) as HTMLElement
if (activeWorkflow) {
activeWorkflow.scrollIntoView({
block: 'start',
})
// 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
// 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)
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)

View File

@@ -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: [],
})
})
})

View File

@@ -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<string, string> = {
'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<string, any>
}
// 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<string, any>
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}