Skip to content

Commit c47ed6c

Browse files
authored
fix(logs): keep compacted child span trees shaped as trees (#8403)
* 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 * fix(logs): keep an output carrying child spans a record when compacting it * fix(logs): split child spans off block output so oversized state still spills * fix(logs): keep a child span tree whole or drop it, bounded as before A structurally compacted tree had no whole-tree bound, so a large one stayed inline in block logs and pause snapshots. A tree still over the threshold after its payloads spill is now dropped (or rejected), as generic compaction bounded it. Block log outputs compact generically again; new logs never carry child spans there. * fix(logs): keep the skeleton of a child span tree too large to keep whole A tree over the threshold as a whole now keeps its shape, names, timing, status, and cost instead of disappearing, using the same content stripping the execution log applies to oversized traces (moved to a shared module). Only a tree whose skeleton is still over the threshold is dropped. * fix(logs): keep span trees well formed and keep nested child workflows in the skeleton The structural walk now keeps only span objects and drops a child list that is not an array, so building a skeleton can never throw on a malformed entry. The skeleton keeps a nested child workflow's output.childTraceSpans. * fix(logs): skip span fields not in their expected shape when summarizing Per-field compaction can spill an oversized modelToolCalls, toolCalls, or providerTiming to a large-value reference; the skeleton now drops such a field instead of reading it.
1 parent e715c5b commit c47ed6c

9 files changed

Lines changed: 658 additions & 82 deletions

File tree

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

Lines changed: 6 additions & 6 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,14 +379,15 @@ export class BlockExecutor {
379379
normalizedOutput = await redactObjectStrings(normalizedOutput, redactionOptions)
380380
}
381381

382-
normalizedOutput = (await compactExecutionPayload(normalizedOutput, {
382+
const compacted = await compactBlockOutput(normalizedOutput, {
383383
workspaceId: blockCtx.workspaceId,
384384
workflowId: blockCtx.workflowId,
385385
executionId: blockCtx.executionId,
386386
userId: blockCtx.userId,
387387
preserveUserFileBase64: blockCtx.includeFileBase64 === true,
388388
requireDurable: true,
389-
})) as NormalizedBlockOutput
389+
})
390+
normalizedOutput = compacted.output
390391

391392
const endedAt = new Date().toISOString()
392393
const duration = performance.now() - startTime
@@ -396,8 +397,8 @@ export class BlockExecutor {
396397
blockLog.durationMs = duration
397398
blockLog.success = true
398399
blockLog.output = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { block })
399-
if (normalizedOutput.childTraceSpans && Array.isArray(normalizedOutput.childTraceSpans)) {
400-
blockLog.childTraceSpans = normalizedOutput.childTraceSpans
400+
if (compacted.childTraceSpans) {
401+
blockLog.childTraceSpans = compacted.childTraceSpans
401402
}
402403
const childExecutionId = normalizedOutput[CHILD_EXECUTION_ID_OUTPUT_KEY]
403404
if (typeof childExecutionId === 'string' && childExecutionId) {
@@ -409,7 +410,6 @@ export class BlockExecutor {
409410
}
410411

411412
const {
412-
childTraceSpans: _traces,
413413
[CHILD_EXECUTION_ID_OUTPUT_KEY]: _childExecutionId,
414414
[CHILD_TRACE_DISABLED_OUTPUT_KEY]: _childTraceDisabled,
415415
...outputForState

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

Lines changed: 246 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {
22
largeValueMetadataMock,
33
largeValueMetadataMockFns,
44
} from '@sim/testing/mocks/large-value-metadata.mock'
5-
import { storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock'
5+
import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock'
66
import { uploadsMock } from '@sim/testing/mocks/uploads.mock'
77
import { beforeEach, describe, expect, it, vi } from 'vitest'
88
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
@@ -15,12 +15,19 @@ 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

2329
vi.mock('@/lib/uploads', () => uploadsMock)
30+
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
2431

2532
vi.mock('@/lib/execution/payloads/large-value-metadata', () => largeValueMetadataMock)
2633

@@ -295,3 +302,239 @@ describe('compactExecutionPayload', () => {
295302
expect(error.message).not.toContain('lv_CQcekP8gSJI5')
296303
})
297304
})
305+
306+
/**
307+
* A child workflow's spans as the workflow block reports them: a loop whose one
308+
* iteration holds two block spans, each with a `resultBytes` payload.
309+
*/
310+
function childWorkflowSpans(resultBytes: number): TraceSpan[] {
311+
const blockSpan = (id: string): TraceSpan => ({
312+
id,
313+
name: id,
314+
type: 'function',
315+
duration: 1,
316+
startTime: '2026-09-29T00:00:00.000Z',
317+
endTime: '2026-09-29T00:00:00.001Z',
318+
output: { result: 'x'.repeat(resultBytes) },
319+
})
320+
return [
321+
{
322+
id: 'loop',
323+
name: 'Loop',
324+
type: 'loop',
325+
duration: 2,
326+
startTime: '2026-09-29T00:00:00.000Z',
327+
endTime: '2026-09-29T00:00:00.002Z',
328+
children: [
329+
{
330+
id: 'iteration-0',
331+
name: 'Iteration 0',
332+
type: 'loop-iteration',
333+
duration: 2,
334+
startTime: '2026-09-29T00:00:00.000Z',
335+
endTime: '2026-09-29T00:00:00.002Z',
336+
children: [blockSpan('span-a'), blockSpan('span-b')],
337+
},
338+
],
339+
},
340+
]
341+
}
342+
343+
/** Spans whose payloads each exceed the 4 KiB test threshold, so each spills on its own. */
344+
const spansWithLargePayloads = () => childWorkflowSpans(8192)
345+
346+
/**
347+
* Spans whose payloads each stay under the 4 KiB test threshold but whose
348+
* iteration `children` together exceed it — the shape generic compaction
349+
* turned into a manifest nested inside the tree.
350+
*/
351+
const spansTooLargeAsAWhole = () => childWorkflowSpans(2500)
352+
353+
/** Asserts the loop → iteration → block span nesting survived with every `children` an array. */
354+
function expectSpanTree(spans: unknown): void {
355+
expect(Array.isArray(spans)).toBe(true)
356+
const [loop] = spans as TraceSpan[]
357+
expect(Array.isArray(loop.children)).toBe(true)
358+
const [iteration] = loop.children ?? []
359+
expect(Array.isArray(iteration.children)).toBe(true)
360+
expect(iteration.children?.map((span) => span.id)).toEqual(['span-a', 'span-b'])
361+
}
362+
363+
describe('compacting span trees', () => {
364+
const options = { thresholdBytes: 4096, requireDurable: true, ...TEST_EXECUTION_CONTEXT }
365+
366+
beforeEach(() => {
367+
clearLargeValueCacheForTests()
368+
mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey }))
369+
mockRegisterLargeValueOwner.mockResolvedValue(true)
370+
})
371+
372+
const childWorkflowLog = (overrides: Partial<BlockLog>): BlockLog => ({
373+
blockId: 'child-workflow',
374+
blockType: 'workflow',
375+
startedAt: '2026-09-29T00:00:00.000Z',
376+
endedAt: '2026-09-29T00:00:00.002Z',
377+
durationMs: 2,
378+
success: true,
379+
...overrides,
380+
})
381+
382+
it('splits a block output child span tree off, spilling each oversized payload', async () => {
383+
const compacted = await compactBlockOutput(
384+
{ result: 'done', childTraceSpans: spansWithLargePayloads() },
385+
options
386+
)
387+
388+
expect(compacted.output).toEqual({ result: 'done' })
389+
expectSpanTree(compacted.childTraceSpans)
390+
const [loop] = compacted.childTraceSpans as TraceSpan[]
391+
const spilled = loop.children?.[0].children?.[0]
392+
expect(isLargeValueRef(spilled?.output?.result)).toBe(true)
393+
})
394+
395+
it('keeps the skeleton of a block output child span tree too large as a whole', async () => {
396+
const compacted = await compactBlockOutput(
397+
{ result: 'done', childTraceSpans: spansTooLargeAsAWhole() },
398+
options
399+
)
400+
401+
expect(compacted.output).toEqual({ result: 'done' })
402+
expectSpanTree(compacted.childTraceSpans)
403+
const [loop] = compacted.childTraceSpans as TraceSpan[]
404+
expect(loop.children?.[0].children?.[0].output).toBeUndefined()
405+
})
406+
407+
it('drops malformed span entries so an oversized tree still keeps its skeleton', async () => {
408+
const spans = spansTooLargeAsAWhole()
409+
const iteration = spans[0].children?.[0]
410+
iteration?.children?.push(null as unknown as TraceSpan)
411+
412+
const compacted = await compactBlockOutput(
413+
{ childTraceSpans: [...spans, undefined as unknown as TraceSpan] },
414+
options
415+
)
416+
417+
expectSpanTree(compacted.childTraceSpans)
418+
expect(compacted.childTraceSpans).toHaveLength(1)
419+
})
420+
421+
it('keeps the skeleton when span metadata itself was spilled', async () => {
422+
const spans = spansTooLargeAsAWhole()
423+
const blockSpan = spans[0].children?.[0].children?.[0]
424+
Object.assign(blockSpan ?? {}, {
425+
modelToolCalls: Array.from({ length: 8 }, (_, index) => ({
426+
name: `tool-${index}`,
427+
arguments: { query: 'q'.repeat(1024) },
428+
})),
429+
toolCalls: Array.from({ length: 8 }, (_, index) => ({
430+
name: `tool-${index}`,
431+
input: 'i'.repeat(1024),
432+
})),
433+
providerTiming: { segments: [{ assistantContent: 'a'.repeat(8192) }] },
434+
})
435+
436+
const compacted = await compactBlockOutput({ childTraceSpans: spans }, options)
437+
438+
expectSpanTree(compacted.childTraceSpans)
439+
})
440+
441+
it('keeps nested child workflow trees in the skeleton', async () => {
442+
const nestedWorkflowSpan: TraceSpan = {
443+
id: 'nested-workflow',
444+
name: 'Nested Workflow',
445+
type: 'workflow',
446+
duration: 2,
447+
startTime: '2026-09-29T00:00:00.000Z',
448+
endTime: '2026-09-29T00:00:00.002Z',
449+
output: { result: 'done', childTraceSpans: spansTooLargeAsAWhole() },
450+
}
451+
452+
const compacted = await compactBlockOutput({ childTraceSpans: [nestedWorkflowSpan] }, options)
453+
454+
const [nested] = compacted.childTraceSpans as TraceSpan[]
455+
expect(nested.output?.result).toBeUndefined()
456+
expectSpanTree(nested.output?.childTraceSpans)
457+
})
458+
459+
it('drops a child span tree whose skeleton alone exceeds the threshold', async () => {
460+
const spans = Array.from({ length: 64 }, (_, index) => ({
461+
...spansTooLargeAsAWhole()[0],
462+
id: `loop-${index}`,
463+
}))
464+
465+
const compacted = await compactBlockOutput({ childTraceSpans: spans }, options)
466+
467+
expect(compacted.childTraceSpans).toBeUndefined()
468+
})
469+
470+
it('rejects a child span tree too large as a whole when large values are rejected', async () => {
471+
await expect(
472+
compactBlockOutput(
473+
{ childTraceSpans: spansTooLargeAsAWhole() },
474+
{ ...options, rejectLargeValues: true }
475+
)
476+
).rejects.toThrow()
477+
})
478+
479+
it('still spills a block output whose fields together exceed the threshold', async () => {
480+
const compacted = await compactBlockOutput(
481+
{
482+
first: 'a'.repeat(2500),
483+
second: 'b'.repeat(2500),
484+
childTraceSpans: spansWithLargePayloads(),
485+
},
486+
options
487+
)
488+
489+
expect(isLargeValueRef(compacted.output)).toBe(true)
490+
expectSpanTree(compacted.childTraceSpans)
491+
})
492+
493+
it('keeps block log child span trees whole or as a skeleton', async () => {
494+
const compacted =
495+
(await compactBlockLogs(
496+
[
497+
childWorkflowLog({ childTraceSpans: spansWithLargePayloads() }),
498+
childWorkflowLog({ childTraceSpans: spansTooLargeAsAWhole() }),
499+
],
500+
options
501+
)) ?? []
502+
503+
expectSpanTree(compacted[0]?.childTraceSpans)
504+
expectSpanTree(compacted[1]?.childTraceSpans)
505+
const [loop] = compacted[1]?.childTraceSpans ?? []
506+
expect(loop.children?.[0].children?.[0].output).toBeUndefined()
507+
})
508+
509+
it('keeps a nested child workflow span tree shaped as a tree', async () => {
510+
const nestedWorkflowSpan: TraceSpan = {
511+
id: 'nested-workflow',
512+
name: 'Nested Workflow',
513+
type: 'workflow',
514+
duration: 2,
515+
startTime: '2026-09-29T00:00:00.000Z',
516+
endTime: '2026-09-29T00:00:00.002Z',
517+
output: { result: 'done', childTraceSpans: spansWithLargePayloads() },
518+
}
519+
520+
const compacted = await compactBlockOutput({ childTraceSpans: [nestedWorkflowSpan] }, options)
521+
522+
const [nested] = compacted.childTraceSpans as TraceSpan[]
523+
expect(nested.output?.result).toBe('done')
524+
expectSpanTree(nested.output?.childTraceSpans)
525+
})
526+
527+
it('terminates on a cyclic span tree', async () => {
528+
const span: TraceSpan = {
529+
id: 'cyclic',
530+
name: 'Cyclic',
531+
type: 'function',
532+
duration: 1,
533+
startTime: '2026-09-29T00:00:00.000Z',
534+
endTime: '2026-09-29T00:00:00.001Z',
535+
}
536+
span.children = [span]
537+
538+
await expect(compactBlockOutput({ childTraceSpans: [span] }, options)).resolves.toBeDefined()
539+
})
540+
})

0 commit comments

Comments
 (0)