Skip to content

Commit b2ecdb3

Browse files
committed
fix(logs): keep compacted child span trees shaped as trees
Block output compaction spilled oversized child span lists to large-value references, so a span's children could stop being an array and trace span building threw while finalizing the run. That left pauses unpersisted and runs unfinalized. - Compact child span trees structurally, spilling only each span's payload fields - Drop non-list child spans with a warning when building trace spans - Finalize without spans if building them fails, so logs, pauses, and billing settle
1 parent 52cc796 commit b2ecdb3

7 files changed

Lines changed: 360 additions & 18 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { isTimeoutAbortReason } from '@/lib/core/execution-limits/types'
77
import { redactApiKeys } from '@/lib/core/security/redaction'
88
import { normalizeStringArray } from '@/lib/core/utils/arrays'
99
import { getBaseUrl } from '@/lib/core/utils/urls'
10-
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
10+
import { compactBlockOutput } from '@/lib/execution/payloads/serializer'
1111
import { redactLargeValueRefsInValue } from '@/lib/logs/execution/pii-large-values'
1212
import { redactObjectStrings } from '@/lib/logs/execution/pii-redaction'
1313
import {
@@ -379,7 +379,7 @@ export class BlockExecutor {
379379
normalizedOutput = await redactObjectStrings(normalizedOutput, redactionOptions)
380380
}
381381

382-
normalizedOutput = (await compactExecutionPayload(normalizedOutput, {
382+
normalizedOutput = (await compactBlockOutput(normalizedOutput, {
383383
workspaceId: blockCtx.workspaceId,
384384
workflowId: blockCtx.workflowId,
385385
executionId: blockCtx.executionId,

‎apps/sim/lib/execution/payloads/serializer.test.ts‎

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,14 @@ import {
1515
getLargeValueMaterializationError,
1616
isLargeValueRef,
1717
} from '@/lib/execution/payloads/large-value-ref'
18-
import { compactExecutionPayload, compactSubflowResults } from '@/lib/execution/payloads/serializer'
19-
import type { UserFile } from '@/executor/types'
18+
import {
19+
compactBlockLogs,
20+
compactBlockOutput,
21+
compactExecutionPayload,
22+
compactSubflowResults,
23+
} from '@/lib/execution/payloads/serializer'
24+
import type { TraceSpan } from '@/lib/logs/types'
25+
import type { BlockLog, UserFile } from '@/executor/types'
2026

2127
const { mockDownloadFile, mockUploadFile } = storageServiceMockFns
2228

@@ -295,3 +301,119 @@ describe('compactExecutionPayload', () => {
295301
expect(error.message).not.toContain('lv_CQcekP8gSJI5')
296302
})
297303
})
304+
305+
/**
306+
* A child workflow's spans as the workflow block reports them: a loop whose one
307+
* iteration holds two block spans. With a 1 KiB threshold each block span stays
308+
* inline, but the iteration's `children` array together is over it — the shape
309+
* generic compaction turned into a manifest.
310+
*/
311+
function childWorkflowSpans(): TraceSpan[] {
312+
const blockSpan = (id: string): TraceSpan => ({
313+
id,
314+
name: id,
315+
type: 'function',
316+
duration: 1,
317+
startTime: '2026-09-29T00:00:00.000Z',
318+
endTime: '2026-09-29T00:00:00.001Z',
319+
output: { result: 'x'.repeat(600) },
320+
})
321+
return [
322+
{
323+
id: 'loop',
324+
name: 'Loop',
325+
type: 'loop',
326+
duration: 2,
327+
startTime: '2026-09-29T00:00:00.000Z',
328+
endTime: '2026-09-29T00:00:00.002Z',
329+
children: [
330+
{
331+
id: 'iteration-0',
332+
name: 'Iteration 0',
333+
type: 'loop-iteration',
334+
duration: 2,
335+
startTime: '2026-09-29T00:00:00.000Z',
336+
endTime: '2026-09-29T00:00:00.002Z',
337+
children: [blockSpan('span-a'), blockSpan('span-b')],
338+
},
339+
],
340+
},
341+
]
342+
}
343+
344+
/** Asserts the loop → iteration → block span nesting survived with every `children` an array. */
345+
function expectSpanTree(spans: unknown): void {
346+
expect(Array.isArray(spans)).toBe(true)
347+
const [loop] = spans as TraceSpan[]
348+
expect(Array.isArray(loop.children)).toBe(true)
349+
const [iteration] = loop.children ?? []
350+
expect(Array.isArray(iteration.children)).toBe(true)
351+
expect(iteration.children?.map((span) => span.id)).toEqual(['span-a', 'span-b'])
352+
}
353+
354+
describe('compacting span trees', () => {
355+
const options = { thresholdBytes: 1024, requireDurable: true, ...TEST_EXECUTION_CONTEXT }
356+
357+
beforeEach(() => {
358+
clearLargeValueCacheForTests()
359+
mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey }))
360+
mockRegisterLargeValueOwner.mockResolvedValue(true)
361+
})
362+
363+
it('keeps a block output child span tree shaped as a tree', async () => {
364+
const compacted = await compactBlockOutput(
365+
{ result: 'done', childTraceSpans: childWorkflowSpans() },
366+
options
367+
)
368+
369+
expectSpanTree(compacted.childTraceSpans)
370+
})
371+
372+
it('still spills an oversized span payload', async () => {
373+
const spans = childWorkflowSpans()
374+
const iteration = spans[0].children?.[0]
375+
if (iteration?.children) iteration.children[0].output = { result: 'y'.repeat(4096) }
376+
377+
const compacted = await compactBlockOutput({ childTraceSpans: spans }, options)
378+
379+
const spilled = (compacted.childTraceSpans as TraceSpan[])[0].children?.[0].children?.[0]
380+
expect(isLargeValueRef(spilled?.output?.result)).toBe(true)
381+
})
382+
383+
it('keeps block log child span trees shaped as trees', async () => {
384+
const log = {
385+
blockId: 'child-workflow',
386+
blockType: 'workflow',
387+
startedAt: '2026-09-29T00:00:00.000Z',
388+
endedAt: '2026-09-29T00:00:00.002Z',
389+
durationMs: 2,
390+
success: true,
391+
childTraceSpans: childWorkflowSpans(),
392+
} as BlockLog
393+
394+
const [compacted] = (await compactBlockLogs([log], options)) ?? []
395+
396+
expectSpanTree(compacted?.childTraceSpans)
397+
})
398+
399+
it('keeps a nested child workflow span tree shaped as a tree', async () => {
400+
const nestedWorkflowSpan: TraceSpan = {
401+
id: 'nested-workflow',
402+
name: 'Nested Workflow',
403+
type: 'workflow',
404+
duration: 2,
405+
startTime: '2026-09-29T00:00:00.000Z',
406+
endTime: '2026-09-29T00:00:00.002Z',
407+
output: { result: 'done', childTraceSpans: childWorkflowSpans() },
408+
}
409+
410+
const compacted = await compactBlockOutput(
411+
{ result: 'done', childTraceSpans: [nestedWorkflowSpan] },
412+
options
413+
)
414+
415+
const [nested] = compacted.childTraceSpans as TraceSpan[]
416+
expect(nested.output?.result).toBe('done')
417+
expectSpanTree(nested.output?.childTraceSpans)
418+
})
419+
})

‎apps/sim/lib/execution/payloads/serializer.ts‎

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isRecordLike } from '@sim/utils/object'
12
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
23
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
34
import {
@@ -254,6 +255,66 @@ export async function compactSubflowResults<T>(
254255
return compactedResults
255256
}
256257

258+
/**
259+
* Compacts a trace span tree without collapsing its structure. Readers walk
260+
* `children` and `output.childTraceSpans` as arrays, so those stay arrays and
261+
* only each span's payload fields are spilled when oversized. Size of the tree
262+
* as a whole is bounded where the log is stored, not here.
263+
*/
264+
async function compactTraceSpanTree(
265+
spans: unknown,
266+
options: CompactExecutionPayloadOptions
267+
): Promise<unknown> {
268+
if (!Array.isArray(spans)) {
269+
return compactExecutionPayload(spans, options)
270+
}
271+
return Promise.all(spans.map((span) => compactTraceSpan(span, options)))
272+
}
273+
274+
async function compactTraceSpan(
275+
span: unknown,
276+
options: CompactExecutionPayloadOptions
277+
): Promise<unknown> {
278+
if (!isRecordLike(span)) {
279+
return compactExecutionPayload(span, options)
280+
}
281+
return Object.fromEntries(
282+
await Promise.all(
283+
Object.entries(span).map(async ([key, value]) => [
284+
key,
285+
key === 'children'
286+
? await compactTraceSpanTree(value, options)
287+
: key === 'output'
288+
? await compactBlockOutput(value, options)
289+
: await compactExecutionPayload(value, options),
290+
])
291+
)
292+
)
293+
}
294+
295+
/**
296+
* Compacts a block output, keeping any `childTraceSpans` shaped as a span tree
297+
* (see {@link compactTraceSpanTree}). Everything else compacts as a normal
298+
* execution payload.
299+
*/
300+
export async function compactBlockOutput<T>(
301+
output: T,
302+
options: CompactExecutionPayloadOptions = {}
303+
): Promise<T> {
304+
if (!isRecordLike(output) || !('childTraceSpans' in output)) {
305+
return compactExecutionPayload(output, options)
306+
}
307+
const { childTraceSpans, ...rest } = output
308+
const [compactedRest, compactedSpans] = await Promise.all([
309+
compactExecutionPayload(rest, options),
310+
compactTraceSpanTree(childTraceSpans, options),
311+
])
312+
if (!isRecordLike(compactedRest)) {
313+
return compactedRest as T
314+
}
315+
return { ...compactedRest, childTraceSpans: compactedSpans } as T
316+
}
317+
257318
export async function compactBlockLogs(
258319
logs: BlockLog[] | undefined,
259320
options: CompactExecutionPayloadOptions = {}
@@ -276,13 +337,13 @@ export async function compactBlockLogs(
276337
compactedLog.input = await compactExecutionPayload(compactedLog.input, options)
277338
}
278339
if ('output' in compactedLog) {
279-
compactedLog.output = await compactExecutionPayload(compactedLog.output, options)
340+
compactedLog.output = await compactBlockOutput(compactedLog.output, options)
280341
}
281342
if ('childTraceSpans' in compactedLog) {
282-
compactedLog.childTraceSpans = await compactExecutionPayload(
343+
compactedLog.childTraceSpans = (await compactTraceSpanTree(
283344
compactedLog.childTraceSpans,
284345
options
285-
)
346+
)) as BlockLog['childTraceSpans']
286347
}
287348
compactedLogs[index] = compactedLog
288349
}

‎apps/sim/lib/logs/execution/trace-spans/span-factory.ts‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -392,8 +392,11 @@ function resolveToolCallsList(output: NormalizedBlockOutput | undefined): BlockT
392392

393393
/** Extracts and flattens child workflow trace spans into the parent span's children. */
394394
function attachChildWorkflowSpans(span: TraceSpan, log: ValidBlockLog): void {
395-
const childTraceSpans = log.childTraceSpans ?? log.output?.childTraceSpans
396-
if (!childTraceSpans?.length) return
395+
const childTraceSpans = readChildSpans(
396+
log.childTraceSpans ?? log.output?.childTraceSpans,
397+
'childTraceSpans'
398+
)
399+
if (childTraceSpans.length === 0) return
397400

398401
span.children = flattenWorkflowChildren(childTraceSpans)
399402
span.output = stripChildTraceSpansFromOutput(span.output)
@@ -404,10 +407,21 @@ function isSyntheticWorkflowWrapper(span: TraceSpan): boolean {
404407
return span.type === 'workflow' && !span.blockId
405408
}
406409

410+
/**
411+
* Reads a list of child spans, or `[]` when absent. Anything other than an
412+
* array (for example a span list that was spilled to a large-value reference)
413+
* is dropped with a warning so one malformed subtree cannot fail the trace.
414+
*/
415+
function readChildSpans(value: unknown, source: 'children' | 'childTraceSpans'): TraceSpan[] {
416+
if (value === undefined || value === null) return []
417+
if (Array.isArray(value)) return value
418+
logger.warn('Dropping child spans that are not a list', { source, shape: typeof value })
419+
return []
420+
}
421+
407422
/** Reads nested `childTraceSpans` off a span's output, or `[]` if absent. */
408423
function extractOutputChildren(output: TraceSpan['output']): TraceSpan[] {
409-
const nested = (output as { childTraceSpans?: TraceSpan[] } | undefined)?.childTraceSpans
410-
return Array.isArray(nested) ? nested : []
424+
return readChildSpans(output?.childTraceSpans, 'childTraceSpans')
411425
}
412426

413427
/** Returns a copy of `output` with `childTraceSpans` removed, or undefined unchanged. */
@@ -430,21 +444,21 @@ export function flattenWorkflowChildren(spans: TraceSpan[]): TraceSpan[] {
430444

431445
for (const span of spans) {
432446
if (isSyntheticWorkflowWrapper(span)) {
433-
if (span.children?.length) {
434-
flattened.push(...flattenWorkflowChildren(span.children))
435-
}
447+
flattened.push(...flattenWorkflowChildren(readChildSpans(span.children, 'children')))
436448
continue
437449
}
438450

439-
const directChildren = span.children ?? []
451+
const directChildren = readChildSpans(span.children, 'children')
440452
const outputChildren = extractOutputChildren(span.output)
441453
const allChildren = [...directChildren, ...outputChildren]
442454

443455
const nextSpan: TraceSpan = { ...span }
444456
if (allChildren.length > 0) {
445457
nextSpan.children = flattenWorkflowChildren(allChildren)
458+
} else if (!Array.isArray(span.children)) {
459+
nextSpan.children = undefined
446460
}
447-
if (outputChildren.length > 0) {
461+
if (span.output && 'childTraceSpans' in span.output) {
448462
nextSpan.output = stripChildTraceSpansFromOutput(nextSpan.output)
449463
}
450464

‎apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1419,3 +1419,65 @@ describe('custom block invoked as an Agent tool', () => {
14191419
expect(toolSpan?.output).not.toHaveProperty('_childTraceDisabled')
14201420
})
14211421
})
1422+
1423+
describe('child span trees that were compacted into references', () => {
1424+
const spilledChildren = {
1425+
__simLargeValueRef: true,
1426+
version: 1,
1427+
id: 'lv_ABCDEFGHIJKLMNOPQRSTUV',
1428+
kind: 'array',
1429+
size: 9_000_000,
1430+
} as unknown as TraceSpan[]
1431+
1432+
function childWorkflowResult(childTraceSpans: TraceSpan[]): ExecutionResult {
1433+
return {
1434+
success: true,
1435+
output: {},
1436+
logs: [
1437+
{
1438+
blockId: 'workflow-1',
1439+
blockName: 'Child Workflow',
1440+
blockType: 'workflow',
1441+
startedAt: '2024-01-01T10:00:00.000Z',
1442+
endedAt: '2024-01-01T10:00:05.000Z',
1443+
durationMs: 5000,
1444+
success: true,
1445+
output: { success: true },
1446+
childTraceSpans,
1447+
},
1448+
],
1449+
}
1450+
}
1451+
1452+
const loopSpan = (overrides: Partial<TraceSpan>): TraceSpan => ({
1453+
id: 'loop-1',
1454+
name: 'Loop',
1455+
type: 'loop',
1456+
blockId: 'loop-1',
1457+
duration: 1000,
1458+
startTime: '2024-01-01T10:00:01.000Z',
1459+
endTime: '2024-01-01T10:00:02.000Z',
1460+
status: 'success',
1461+
...overrides,
1462+
})
1463+
1464+
it.concurrent('keeps the span when its children are a reference', () => {
1465+
const { traceSpans } = buildTraceSpans(
1466+
childWorkflowResult([loopSpan({ children: spilledChildren })])
1467+
)
1468+
1469+
expect(traceSpans[0].children?.map((span) => span.id)).toEqual(['loop-1'])
1470+
expect(traceSpans[0].children?.[0].children).toBeUndefined()
1471+
})
1472+
1473+
it.concurrent('keeps the span when its output child spans are a reference', () => {
1474+
const { traceSpans } = buildTraceSpans(
1475+
childWorkflowResult([
1476+
loopSpan({ output: { result: 'done', childTraceSpans: spilledChildren } }),
1477+
])
1478+
)
1479+
1480+
expect(traceSpans[0].children?.map((span) => span.id)).toEqual(['loop-1'])
1481+
expect(traceSpans[0].children?.[0].output).toEqual({ result: 'done' })
1482+
})
1483+
})

0 commit comments

Comments
 (0)