mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-11 07:04:58 -05:00
fix(logs): surface handled errors as info in logs (#3190)
* fix(logs): surface handled errors as info in logs * pr comments
This commit is contained in:
@@ -67,7 +67,7 @@ function parseTime(value?: string | number | null): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a span or any of its descendants has an error
|
||||
* Checks if a span or any of its descendants has an error (any error).
|
||||
*/
|
||||
function hasErrorInTree(span: TraceSpan): boolean {
|
||||
if (span.status === 'error') return true
|
||||
@@ -80,6 +80,23 @@ function hasErrorInTree(span: TraceSpan): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a span or any of its descendants has an unhandled error.
|
||||
* Spans with errorHandled: true (including containers that propagate it)
|
||||
* are skipped. Used only for the root workflow span to match the actual
|
||||
* workflow status.
|
||||
*/
|
||||
function hasUnhandledErrorInTree(span: TraceSpan): boolean {
|
||||
if (span.status === 'error' && !span.errorHandled) return true
|
||||
if (span.children && span.children.length > 0) {
|
||||
return span.children.some((child) => hasUnhandledErrorInTree(child))
|
||||
}
|
||||
if (span.toolCalls && span.toolCalls.length > 0 && !span.errorHandled) {
|
||||
return span.toolCalls.some((tc) => tc.error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes and sorts trace spans recursively.
|
||||
* Deduplicates children and sorts by start time.
|
||||
@@ -478,14 +495,13 @@ const TraceSpanNode = memo(function TraceSpanNode({
|
||||
const duration = span.duration || spanEndTime - spanStartTime
|
||||
|
||||
const isDirectError = span.status === 'error'
|
||||
const hasNestedError = hasErrorInTree(span)
|
||||
const isRootWorkflow = depth === 0
|
||||
const isRootWorkflowSpan = isRootWorkflow && span.type?.toLowerCase() === 'workflow'
|
||||
const hasNestedError = isRootWorkflowSpan ? hasUnhandledErrorInTree(span) : hasErrorInTree(span)
|
||||
const showErrorStyle = isDirectError || hasNestedError
|
||||
|
||||
const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type, span.name)
|
||||
|
||||
// Root workflow execution is always expanded and has no toggle
|
||||
const isRootWorkflow = depth === 0
|
||||
|
||||
// Build all children including tool calls
|
||||
const allChildren = useMemo(() => {
|
||||
const children: TraceSpan[] = []
|
||||
|
||||
@@ -95,7 +95,6 @@ const WorkflowEdgeComponent = ({
|
||||
color = 'var(--brand-tertiary-2)'
|
||||
} else if (edgeRunStatus === 'success') {
|
||||
// Use green for preview mode, default for canvas execution
|
||||
// This also applies to error edges that were taken (error path executed)
|
||||
color = previewExecutionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)'
|
||||
} else if (edgeRunStatus === 'error') {
|
||||
color = 'var(--text-error)'
|
||||
|
||||
@@ -55,7 +55,7 @@ interface DebugValidationResult {
|
||||
interface BlockEventHandlerConfig {
|
||||
workflowId?: string
|
||||
executionId?: string
|
||||
workflowEdges: Array<{ id: string; target: string }>
|
||||
workflowEdges: Array<{ id: string; target: string; sourceHandle?: string | null }>
|
||||
activeBlocksSet: Set<string>
|
||||
accumulatedBlockLogs: BlockLog[]
|
||||
accumulatedBlockStates: Map<string, BlockState>
|
||||
@@ -322,7 +322,8 @@ export function useWorkflowExecution() {
|
||||
if (!workflowId) return
|
||||
const incomingEdges = workflowEdges.filter((edge) => edge.target === blockId)
|
||||
incomingEdges.forEach((edge) => {
|
||||
setEdgeRunStatus(workflowId, edge.id, 'success')
|
||||
const status = edge.sourceHandle === 'error' ? 'error' : 'success'
|
||||
setEdgeRunStatus(workflowId, edge.id, status)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -292,6 +292,9 @@ export class BlockExecutor {
|
||||
|
||||
const hasErrorPort = this.hasErrorPortEdge(node)
|
||||
if (hasErrorPort) {
|
||||
if (blockLog) {
|
||||
blockLog.errorHandled = true
|
||||
}
|
||||
logger.info('Block has error port - returning error output instead of throwing', {
|
||||
blockId: node.id,
|
||||
error: errorMessage,
|
||||
|
||||
@@ -112,6 +112,8 @@ export interface BlockLog {
|
||||
output?: any
|
||||
input?: any
|
||||
error?: string
|
||||
/** Whether this error was handled by an error handler path (error port) */
|
||||
errorHandled?: boolean
|
||||
loopId?: string
|
||||
parallelId?: string
|
||||
iterationIndex?: number
|
||||
|
||||
@@ -219,11 +219,12 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
| { traceSpans?: TraceSpan[] }
|
||||
| undefined
|
||||
|
||||
// Determine if workflow failed by checking trace spans for errors
|
||||
// Determine if workflow failed by checking trace spans for unhandled errors
|
||||
// Errors handled by error handler paths (errorHandled: true) don't count as workflow failures
|
||||
// Use the override if provided (for cost-only fallback scenarios)
|
||||
const hasErrors = traceSpans?.some((span: any) => {
|
||||
const checkSpanForErrors = (s: any): boolean => {
|
||||
if (s.status === 'error') return true
|
||||
if (s.status === 'error' && !s.errorHandled) return true
|
||||
if (s.children && Array.isArray(s.children)) {
|
||||
return s.children.some(checkSpanForErrors)
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@ export class LoggingSession {
|
||||
|
||||
const hasErrors = traceSpans.some((span: any) => {
|
||||
const checkForErrors = (s: any): boolean => {
|
||||
if (s.status === 'error') return true
|
||||
if (s.status === 'error' && !s.errorHandled) return true
|
||||
if (s.children && Array.isArray(s.children)) {
|
||||
return s.children.some(checkForErrors)
|
||||
}
|
||||
|
||||
@@ -990,6 +990,535 @@ describe('buildTraceSpans', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('errorHandled - handled errors should not bubble up', () => {
|
||||
it.concurrent('block span stays error but is marked errorHandled', () => {
|
||||
const result: ExecutionResult = {
|
||||
success: true,
|
||||
output: { content: 'done' },
|
||||
logs: [
|
||||
{
|
||||
blockId: 'api-1',
|
||||
blockName: 'API Call',
|
||||
blockType: 'api',
|
||||
startedAt: '2024-01-01T10:00:00.000Z',
|
||||
endedAt: '2024-01-01T10:00:01.000Z',
|
||||
durationMs: 1000,
|
||||
success: false,
|
||||
error: 'Request failed with status 500',
|
||||
errorHandled: true,
|
||||
executionOrder: 1,
|
||||
},
|
||||
{
|
||||
blockId: 'fallback-1',
|
||||
blockName: 'Fallback',
|
||||
blockType: 'function',
|
||||
startedAt: '2024-01-01T10:00:01.000Z',
|
||||
endedAt: '2024-01-01T10:00:02.000Z',
|
||||
durationMs: 1000,
|
||||
success: true,
|
||||
executionOrder: 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const { traceSpans } = buildTraceSpans(result)
|
||||
|
||||
const apiSpan = traceSpans.find((s) => s.blockId === 'api-1')!
|
||||
expect(apiSpan.status).toBe('error')
|
||||
expect(apiSpan.errorHandled).toBe(true)
|
||||
expect((apiSpan.output as { error?: string }).error).toBe('Request failed with status 500')
|
||||
|
||||
const fallbackSpan = traceSpans.find((s) => s.blockId === 'fallback-1')!
|
||||
expect(fallbackSpan.status).toBe('success')
|
||||
expect(fallbackSpan.errorHandled).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('unhandled errors still produce error status', () => {
|
||||
const result: ExecutionResult = {
|
||||
success: false,
|
||||
output: {},
|
||||
metadata: { duration: 1000, startTime: '2024-01-01T10:00:00.000Z' },
|
||||
logs: [
|
||||
{
|
||||
blockId: 'api-1',
|
||||
blockName: 'API Call',
|
||||
blockType: 'api',
|
||||
startedAt: '2024-01-01T10:00:00.000Z',
|
||||
endedAt: '2024-01-01T10:00:01.000Z',
|
||||
durationMs: 1000,
|
||||
success: false,
|
||||
error: 'Request failed with status 500',
|
||||
executionOrder: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const { traceSpans } = buildTraceSpans(result)
|
||||
|
||||
const workflowSpan = traceSpans[0]
|
||||
expect(workflowSpan.name).toBe('Workflow Execution')
|
||||
expect(workflowSpan.status).toBe('error')
|
||||
|
||||
const apiSpan = workflowSpan.children![0]
|
||||
expect(apiSpan.status).toBe('error')
|
||||
expect(apiSpan.errorHandled).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('workflow-level span is success when all errors are handled', () => {
|
||||
const result: ExecutionResult = {
|
||||
success: true,
|
||||
output: { content: 'recovered' },
|
||||
metadata: { duration: 2000, startTime: '2024-01-01T10:00:00.000Z' },
|
||||
logs: [
|
||||
{
|
||||
blockId: 'api-1',
|
||||
blockName: 'API Call',
|
||||
blockType: 'api',
|
||||
startedAt: '2024-01-01T10:00:00.000Z',
|
||||
endedAt: '2024-01-01T10:00:01.000Z',
|
||||
durationMs: 1000,
|
||||
success: false,
|
||||
error: 'Connection timeout',
|
||||
errorHandled: true,
|
||||
executionOrder: 1,
|
||||
},
|
||||
{
|
||||
blockId: 'handler-1',
|
||||
blockName: 'Error Handler',
|
||||
blockType: 'function',
|
||||
startedAt: '2024-01-01T10:00:01.000Z',
|
||||
endedAt: '2024-01-01T10:00:02.000Z',
|
||||
durationMs: 1000,
|
||||
success: true,
|
||||
executionOrder: 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const { traceSpans } = buildTraceSpans(result)
|
||||
|
||||
const workflowSpan = traceSpans[0]
|
||||
expect(workflowSpan.name).toBe('Workflow Execution')
|
||||
expect(workflowSpan.status).toBe('success')
|
||||
})
|
||||
|
||||
it.concurrent(
|
||||
'workflow-level span is error when there is a mix of handled and unhandled errors',
|
||||
() => {
|
||||
const result: ExecutionResult = {
|
||||
success: false,
|
||||
output: {},
|
||||
metadata: { duration: 3000, startTime: '2024-01-01T10:00:00.000Z' },
|
||||
logs: [
|
||||
{
|
||||
blockId: 'api-1',
|
||||
blockName: 'API Call (handled)',
|
||||
blockType: 'api',
|
||||
startedAt: '2024-01-01T10:00:00.000Z',
|
||||
endedAt: '2024-01-01T10:00:01.000Z',
|
||||
durationMs: 1000,
|
||||
success: false,
|
||||
error: 'Handled error',
|
||||
errorHandled: true,
|
||||
executionOrder: 1,
|
||||
},
|
||||
{
|
||||
blockId: 'handler-1',
|
||||
blockName: 'Error Handler',
|
||||
blockType: 'function',
|
||||
startedAt: '2024-01-01T10:00:01.000Z',
|
||||
endedAt: '2024-01-01T10:00:02.000Z',
|
||||
durationMs: 1000,
|
||||
success: true,
|
||||
executionOrder: 2,
|
||||
},
|
||||
{
|
||||
blockId: 'api-2',
|
||||
blockName: 'API Call (unhandled)',
|
||||
blockType: 'api',
|
||||
startedAt: '2024-01-01T10:00:02.000Z',
|
||||
endedAt: '2024-01-01T10:00:03.000Z',
|
||||
durationMs: 1000,
|
||||
success: false,
|
||||
error: 'Unhandled crash',
|
||||
executionOrder: 3,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const { traceSpans } = buildTraceSpans(result)
|
||||
|
||||
const workflowSpan = traceSpans[0]
|
||||
expect(workflowSpan.name).toBe('Workflow Execution')
|
||||
expect(workflowSpan.status).toBe('error')
|
||||
|
||||
const handledSpan = workflowSpan.children!.find((s) => s.blockId === 'api-1')!
|
||||
expect(handledSpan.status).toBe('error')
|
||||
expect(handledSpan.errorHandled).toBe(true)
|
||||
|
||||
const unhandledSpan = workflowSpan.children!.find((s) => s.blockId === 'api-2')!
|
||||
expect(unhandledSpan.status).toBe('error')
|
||||
expect(unhandledSpan.errorHandled).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent(
|
||||
'handled errors inside loop iterations still show error on loop but not on workflow',
|
||||
() => {
|
||||
const result: ExecutionResult = {
|
||||
success: true,
|
||||
output: { content: 'all iterations recovered' },
|
||||
metadata: { duration: 5000, startTime: '2024-01-01T10:00:00.000Z' },
|
||||
logs: [
|
||||
{
|
||||
blockId: 'api-1',
|
||||
blockName: 'API Call (iteration 0)',
|
||||
blockType: 'api',
|
||||
startedAt: '2024-01-01T10:00:00.000Z',
|
||||
endedAt: '2024-01-01T10:00:01.000Z',
|
||||
durationMs: 1000,
|
||||
success: false,
|
||||
error: 'Rate limited',
|
||||
errorHandled: true,
|
||||
loopId: 'loop-1',
|
||||
iterationIndex: 0,
|
||||
executionOrder: 1,
|
||||
},
|
||||
{
|
||||
blockId: 'handler-1',
|
||||
blockName: 'Rate Limit Handler (iteration 0)',
|
||||
blockType: 'function',
|
||||
startedAt: '2024-01-01T10:00:01.000Z',
|
||||
endedAt: '2024-01-01T10:00:02.000Z',
|
||||
durationMs: 1000,
|
||||
success: true,
|
||||
loopId: 'loop-1',
|
||||
iterationIndex: 0,
|
||||
executionOrder: 2,
|
||||
},
|
||||
{
|
||||
blockId: 'api-1',
|
||||
blockName: 'API Call (iteration 1)',
|
||||
blockType: 'api',
|
||||
startedAt: '2024-01-01T10:00:02.000Z',
|
||||
endedAt: '2024-01-01T10:00:03.000Z',
|
||||
durationMs: 1000,
|
||||
success: true,
|
||||
loopId: 'loop-1',
|
||||
iterationIndex: 1,
|
||||
executionOrder: 3,
|
||||
},
|
||||
{
|
||||
blockId: 'api-1',
|
||||
blockName: 'API Call (iteration 2)',
|
||||
blockType: 'api',
|
||||
startedAt: '2024-01-01T10:00:03.000Z',
|
||||
endedAt: '2024-01-01T10:00:04.000Z',
|
||||
durationMs: 1000,
|
||||
success: false,
|
||||
error: 'Rate limited again',
|
||||
errorHandled: true,
|
||||
loopId: 'loop-1',
|
||||
iterationIndex: 2,
|
||||
executionOrder: 4,
|
||||
},
|
||||
{
|
||||
blockId: 'handler-1',
|
||||
blockName: 'Rate Limit Handler (iteration 2)',
|
||||
blockType: 'function',
|
||||
startedAt: '2024-01-01T10:00:04.000Z',
|
||||
endedAt: '2024-01-01T10:00:05.000Z',
|
||||
durationMs: 1000,
|
||||
success: true,
|
||||
loopId: 'loop-1',
|
||||
iterationIndex: 2,
|
||||
executionOrder: 5,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const { traceSpans } = buildTraceSpans(result)
|
||||
|
||||
const workflowSpan = traceSpans[0]
|
||||
expect(workflowSpan.name).toBe('Workflow Execution')
|
||||
expect(workflowSpan.status).toBe('success')
|
||||
|
||||
const loopSpan = workflowSpan.children!.find((s) => s.type === 'loop')!
|
||||
expect(loopSpan).toBeDefined()
|
||||
expect(loopSpan.status).toBe('error')
|
||||
|
||||
const iterations = loopSpan.children!
|
||||
expect(iterations).toHaveLength(3)
|
||||
expect(iterations[0].status).toBe('error')
|
||||
expect(iterations[1].status).toBe('success')
|
||||
expect(iterations[2].status).toBe('error')
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent(
|
||||
'handled errors inside parallel iterations still show error on parallel but not on workflow',
|
||||
() => {
|
||||
const result: ExecutionResult = {
|
||||
success: true,
|
||||
output: { content: 'parallel done' },
|
||||
metadata: { duration: 2000, startTime: '2024-01-01T10:00:00.000Z' },
|
||||
logs: [
|
||||
{
|
||||
blockId: 'api-1',
|
||||
blockName: 'API Call (iteration 0)',
|
||||
blockType: 'api',
|
||||
startedAt: '2024-01-01T10:00:00.000Z',
|
||||
endedAt: '2024-01-01T10:00:01.000Z',
|
||||
durationMs: 1000,
|
||||
success: false,
|
||||
error: 'Timeout on iteration 0',
|
||||
errorHandled: true,
|
||||
parallelId: 'parallel-1',
|
||||
iterationIndex: 0,
|
||||
executionOrder: 1,
|
||||
},
|
||||
{
|
||||
blockId: 'api-1',
|
||||
blockName: 'API Call (iteration 1)',
|
||||
blockType: 'api',
|
||||
startedAt: '2024-01-01T10:00:01.000Z',
|
||||
endedAt: '2024-01-01T10:00:02.000Z',
|
||||
durationMs: 1000,
|
||||
success: true,
|
||||
parallelId: 'parallel-1',
|
||||
iterationIndex: 1,
|
||||
executionOrder: 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const { traceSpans } = buildTraceSpans(result)
|
||||
|
||||
const workflowSpan = traceSpans[0]
|
||||
expect(workflowSpan.name).toBe('Workflow Execution')
|
||||
expect(workflowSpan.status).toBe('success')
|
||||
|
||||
const parallelSpan = workflowSpan.children!.find((s) => s.type === 'parallel')!
|
||||
expect(parallelSpan).toBeDefined()
|
||||
expect(parallelSpan.status).toBe('error')
|
||||
|
||||
const iterations = parallelSpan.children!
|
||||
expect(iterations).toHaveLength(2)
|
||||
expect(iterations[0].status).toBe('error')
|
||||
expect(iterations[1].status).toBe('success')
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent(
|
||||
'unhandled error in one loop iteration still makes the loop and workflow error',
|
||||
() => {
|
||||
const result: ExecutionResult = {
|
||||
success: false,
|
||||
output: {},
|
||||
metadata: { duration: 2000, startTime: '2024-01-01T10:00:00.000Z' },
|
||||
logs: [
|
||||
{
|
||||
blockId: 'api-1',
|
||||
blockName: 'API Call (iteration 0)',
|
||||
blockType: 'api',
|
||||
startedAt: '2024-01-01T10:00:00.000Z',
|
||||
endedAt: '2024-01-01T10:00:01.000Z',
|
||||
durationMs: 1000,
|
||||
success: true,
|
||||
loopId: 'loop-1',
|
||||
iterationIndex: 0,
|
||||
executionOrder: 1,
|
||||
},
|
||||
{
|
||||
blockId: 'api-1',
|
||||
blockName: 'API Call (iteration 1)',
|
||||
blockType: 'api',
|
||||
startedAt: '2024-01-01T10:00:01.000Z',
|
||||
endedAt: '2024-01-01T10:00:02.000Z',
|
||||
durationMs: 1000,
|
||||
success: false,
|
||||
error: 'Unhandled crash in iteration 1',
|
||||
loopId: 'loop-1',
|
||||
iterationIndex: 1,
|
||||
executionOrder: 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const { traceSpans } = buildTraceSpans(result)
|
||||
|
||||
const workflowSpan = traceSpans[0]
|
||||
expect(workflowSpan.name).toBe('Workflow Execution')
|
||||
expect(workflowSpan.status).toBe('error')
|
||||
|
||||
const loopSpan = workflowSpan.children!.find((s) => s.type === 'loop')!
|
||||
expect(loopSpan.status).toBe('error')
|
||||
|
||||
const iterations = loopSpan.children!
|
||||
expect(iterations[0].status).toBe('success')
|
||||
expect(iterations[1].status).toBe('error')
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent('error output is preserved on the span even when error is handled', () => {
|
||||
const result: ExecutionResult = {
|
||||
success: true,
|
||||
output: { content: 'recovered' },
|
||||
logs: [
|
||||
{
|
||||
blockId: 'api-1',
|
||||
blockName: 'Flaky API',
|
||||
blockType: 'api',
|
||||
startedAt: '2024-01-01T10:00:00.000Z',
|
||||
endedAt: '2024-01-01T10:00:01.000Z',
|
||||
durationMs: 1000,
|
||||
success: false,
|
||||
error: 'ECONNRESET',
|
||||
errorHandled: true,
|
||||
output: { error: 'ECONNRESET' },
|
||||
executionOrder: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const { traceSpans } = buildTraceSpans(result)
|
||||
|
||||
const apiSpan = traceSpans[0]
|
||||
expect(apiSpan.status).toBe('error')
|
||||
expect(apiSpan.errorHandled).toBe(true)
|
||||
expect((apiSpan.output as { error?: string }).error).toBe('ECONNRESET')
|
||||
})
|
||||
|
||||
it.concurrent('block with error and errorHandled=false is treated as unhandled', () => {
|
||||
const result: ExecutionResult = {
|
||||
success: false,
|
||||
output: {},
|
||||
metadata: { duration: 1000, startTime: '2024-01-01T10:00:00.000Z' },
|
||||
logs: [
|
||||
{
|
||||
blockId: 'api-1',
|
||||
blockName: 'API Call',
|
||||
blockType: 'api',
|
||||
startedAt: '2024-01-01T10:00:00.000Z',
|
||||
endedAt: '2024-01-01T10:00:01.000Z',
|
||||
durationMs: 1000,
|
||||
success: false,
|
||||
error: 'Server error',
|
||||
errorHandled: false,
|
||||
executionOrder: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const { traceSpans } = buildTraceSpans(result)
|
||||
|
||||
const workflowSpan = traceSpans[0]
|
||||
expect(workflowSpan.name).toBe('Workflow Execution')
|
||||
expect(workflowSpan.status).toBe('error')
|
||||
|
||||
const apiSpan = workflowSpan.children![0]
|
||||
expect(apiSpan.status).toBe('error')
|
||||
})
|
||||
|
||||
it.concurrent('many loop iterations with handled errors produce a successful workflow', () => {
|
||||
const logs = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const startMs = 1704103200000 + i * 2000
|
||||
if (i % 3 === 0) {
|
||||
logs.push({
|
||||
blockId: 'api-1',
|
||||
blockName: `API Call (iteration ${i})`,
|
||||
blockType: 'api',
|
||||
startedAt: new Date(startMs).toISOString(),
|
||||
endedAt: new Date(startMs + 1000).toISOString(),
|
||||
durationMs: 1000,
|
||||
success: false,
|
||||
error: `Error in iteration ${i}`,
|
||||
errorHandled: true,
|
||||
loopId: 'loop-1',
|
||||
iterationIndex: i,
|
||||
executionOrder: i * 2 + 1,
|
||||
})
|
||||
logs.push({
|
||||
blockId: 'handler-1',
|
||||
blockName: `Error Handler (iteration ${i})`,
|
||||
blockType: 'function',
|
||||
startedAt: new Date(startMs + 1000).toISOString(),
|
||||
endedAt: new Date(startMs + 2000).toISOString(),
|
||||
durationMs: 1000,
|
||||
success: true,
|
||||
loopId: 'loop-1',
|
||||
iterationIndex: i,
|
||||
executionOrder: i * 2 + 2,
|
||||
})
|
||||
} else {
|
||||
logs.push({
|
||||
blockId: 'api-1',
|
||||
blockName: `API Call (iteration ${i})`,
|
||||
blockType: 'api',
|
||||
startedAt: new Date(startMs).toISOString(),
|
||||
endedAt: new Date(startMs + 1000).toISOString(),
|
||||
durationMs: 1000,
|
||||
success: true,
|
||||
loopId: 'loop-1',
|
||||
iterationIndex: i,
|
||||
executionOrder: i * 2 + 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const result: ExecutionResult = {
|
||||
success: true,
|
||||
output: { content: 'all done' },
|
||||
metadata: { duration: 20000, startTime: '2024-01-01T10:00:00.000Z' },
|
||||
logs: logs as any,
|
||||
}
|
||||
|
||||
const { traceSpans } = buildTraceSpans(result)
|
||||
|
||||
const workflowSpan = traceSpans[0]
|
||||
expect(workflowSpan.name).toBe('Workflow Execution')
|
||||
expect(workflowSpan.status).toBe('success')
|
||||
|
||||
const loopSpan = workflowSpan.children!.find((s) => s.type === 'loop')!
|
||||
expect(loopSpan).toBeDefined()
|
||||
expect(loopSpan.status).toBe('error')
|
||||
|
||||
loopSpan.children!.forEach((iteration, i) => {
|
||||
if (i % 3 === 0) {
|
||||
expect(iteration.status).toBe('error')
|
||||
} else {
|
||||
expect(iteration.status).toBe('success')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('successful blocks without errors have no errorHandled flag', () => {
|
||||
const result: ExecutionResult = {
|
||||
success: true,
|
||||
output: { content: 'fine' },
|
||||
logs: [
|
||||
{
|
||||
blockId: 'text-1',
|
||||
blockName: 'Text Block',
|
||||
blockType: 'text',
|
||||
startedAt: '2024-01-01T10:00:00.000Z',
|
||||
endedAt: '2024-01-01T10:00:01.000Z',
|
||||
durationMs: 1000,
|
||||
success: true,
|
||||
executionOrder: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const { traceSpans } = buildTraceSpans(result)
|
||||
|
||||
const span = traceSpans[0]
|
||||
expect(span.status).toBe('success')
|
||||
expect(span.errorHandled).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripCustomToolPrefix', () => {
|
||||
it.concurrent('strips custom_ prefix from tool names', () => {
|
||||
expect(stripCustomToolPrefix('custom_test_tool')).toBe('test_tool')
|
||||
|
||||
@@ -156,6 +156,7 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
output: output,
|
||||
...(childWorkflowSnapshotId ? { childWorkflowSnapshotId } : {}),
|
||||
...(childWorkflowId ? { childWorkflowId } : {}),
|
||||
...(log.errorHandled && { errorHandled: true }),
|
||||
...(log.loopId && { loopId: log.loopId }),
|
||||
...(log.parallelId && { parallelId: log.parallelId }),
|
||||
...(log.iterationIndex !== undefined && { iterationIndex: log.iterationIndex }),
|
||||
@@ -501,15 +502,11 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
}
|
||||
addRelativeTimestamps(groupedRootSpans, earliestStart)
|
||||
|
||||
const hasErrors = groupedRootSpans.some((span) => {
|
||||
if (span.status === 'error') return true
|
||||
const checkChildren = (children: TraceSpan[] = []): boolean => {
|
||||
return children.some(
|
||||
(child) => child.status === 'error' || (child.children && checkChildren(child.children))
|
||||
)
|
||||
}
|
||||
return span.children && checkChildren(span.children)
|
||||
})
|
||||
const checkForUnhandledErrors = (s: TraceSpan): boolean => {
|
||||
if (s.status === 'error' && !s.errorHandled) return true
|
||||
return s.children ? s.children.some(checkForUnhandledErrors) : false
|
||||
}
|
||||
const hasUnhandledErrors = groupedRootSpans.some(checkForUnhandledErrors)
|
||||
|
||||
const workflowSpan: TraceSpan = {
|
||||
id: 'workflow-execution',
|
||||
@@ -518,7 +515,7 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
duration: actualWorkflowDuration, // Always use actual duration for the span
|
||||
startTime: new Date(earliestStart).toISOString(),
|
||||
endTime: new Date(latestEnd).toISOString(),
|
||||
status: hasErrors ? 'error' : 'success',
|
||||
status: hasUnhandledErrors ? 'error' : 'success',
|
||||
children: groupedRootSpans,
|
||||
}
|
||||
|
||||
@@ -710,6 +707,8 @@ function groupIterationBlocks(spans: TraceSpan[]): TraceSpan[] {
|
||||
const iterDuration = iterLatestEnd - iterEarliestStart
|
||||
|
||||
const hasErrors = spans.some((span) => span.status === 'error')
|
||||
const allErrorsHandled =
|
||||
hasErrors && spans.every((span) => span.status !== 'error' || span.errorHandled)
|
||||
|
||||
const iterationSpan: TraceSpan = {
|
||||
id: `${containerId}-iteration-${iterationIndex}`,
|
||||
@@ -719,6 +718,7 @@ function groupIterationBlocks(spans: TraceSpan[]): TraceSpan[] {
|
||||
startTime: new Date(iterEarliestStart).toISOString(),
|
||||
endTime: new Date(iterLatestEnd).toISOString(),
|
||||
status: hasErrors ? 'error' : 'success',
|
||||
...(allErrorsHandled && { errorHandled: true }),
|
||||
children: spans.map((span) => ({
|
||||
...span,
|
||||
name: span.name.replace(/ \(iteration \d+\)$/, ''),
|
||||
@@ -729,6 +729,9 @@ function groupIterationBlocks(spans: TraceSpan[]): TraceSpan[] {
|
||||
})
|
||||
|
||||
const hasErrors = allIterationSpans.some((span) => span.status === 'error')
|
||||
const allErrorsHandled =
|
||||
hasErrors &&
|
||||
iterationChildren.every((span) => span.status !== 'error' || span.errorHandled)
|
||||
const parallelContainer: TraceSpan = {
|
||||
id: `parallel-execution-${containerId}`,
|
||||
name: containerName,
|
||||
@@ -737,6 +740,7 @@ function groupIterationBlocks(spans: TraceSpan[]): TraceSpan[] {
|
||||
startTime: new Date(earliestStart).toISOString(),
|
||||
endTime: new Date(latestEnd).toISOString(),
|
||||
status: hasErrors ? 'error' : 'success',
|
||||
...(allErrorsHandled && { errorHandled: true }),
|
||||
children: iterationChildren,
|
||||
}
|
||||
|
||||
@@ -762,6 +766,8 @@ function groupIterationBlocks(spans: TraceSpan[]): TraceSpan[] {
|
||||
const iterDuration = iterLatestEnd - iterEarliestStart
|
||||
|
||||
const hasErrors = spans.some((span) => span.status === 'error')
|
||||
const allErrorsHandled =
|
||||
hasErrors && spans.every((span) => span.status !== 'error' || span.errorHandled)
|
||||
|
||||
const iterationSpan: TraceSpan = {
|
||||
id: `${containerId}-iteration-${iterationIndex}`,
|
||||
@@ -771,6 +777,7 @@ function groupIterationBlocks(spans: TraceSpan[]): TraceSpan[] {
|
||||
startTime: new Date(iterEarliestStart).toISOString(),
|
||||
endTime: new Date(iterLatestEnd).toISOString(),
|
||||
status: hasErrors ? 'error' : 'success',
|
||||
...(allErrorsHandled && { errorHandled: true }),
|
||||
children: spans.map((span) => ({
|
||||
...span,
|
||||
name: span.name.replace(/ \(iteration \d+\)$/, ''),
|
||||
@@ -781,6 +788,9 @@ function groupIterationBlocks(spans: TraceSpan[]): TraceSpan[] {
|
||||
})
|
||||
|
||||
const hasErrors = allIterationSpans.some((span) => span.status === 'error')
|
||||
const allErrorsHandled =
|
||||
hasErrors &&
|
||||
iterationChildren.every((span) => span.status !== 'error' || span.errorHandled)
|
||||
const loopContainer: TraceSpan = {
|
||||
id: `loop-execution-${containerId}`,
|
||||
name: containerName,
|
||||
@@ -789,6 +799,7 @@ function groupIterationBlocks(spans: TraceSpan[]): TraceSpan[] {
|
||||
startTime: new Date(earliestStart).toISOString(),
|
||||
endTime: new Date(latestEnd).toISOString(),
|
||||
status: hasErrors ? 'error' : 'success',
|
||||
...(allErrorsHandled && { errorHandled: true }),
|
||||
children: iterationChildren,
|
||||
}
|
||||
|
||||
|
||||
@@ -175,6 +175,8 @@ export interface TraceSpan {
|
||||
children?: TraceSpan[]
|
||||
toolCalls?: ToolCall[]
|
||||
status?: 'success' | 'error'
|
||||
/** Whether this block's error was handled by an error handler path */
|
||||
errorHandled?: boolean
|
||||
tokens?: number | TokenInfo
|
||||
relativeStartMs?: number
|
||||
blockId?: string
|
||||
|
||||
@@ -86,6 +86,7 @@ export interface TraceSpan {
|
||||
children?: TraceSpan[]
|
||||
toolCalls?: ToolCall[]
|
||||
status?: 'success' | 'error'
|
||||
errorHandled?: boolean
|
||||
tokens?: number | TokenInfo
|
||||
relativeStartMs?: number // Time in ms from the start of the parent span
|
||||
blockId?: string // Added to track the original block ID for relationship mapping
|
||||
|
||||
Reference in New Issue
Block a user