Skip to content

Commit 2e2206d

Browse files
committed
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.
1 parent 5ce7f18 commit 2e2206d

3 files changed

Lines changed: 50 additions & 12 deletions

File tree

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,38 @@ describe('compacting span trees', () => {
404404
expect(loop.children?.[0].children?.[0].output).toBeUndefined()
405405
})
406406

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 nested child workflow trees in the skeleton', async () => {
422+
const nestedWorkflowSpan: TraceSpan = {
423+
id: 'nested-workflow',
424+
name: 'Nested Workflow',
425+
type: 'workflow',
426+
duration: 2,
427+
startTime: '2026-09-29T00:00:00.000Z',
428+
endTime: '2026-09-29T00:00:00.002Z',
429+
output: { result: 'done', childTraceSpans: spansTooLargeAsAWhole() },
430+
}
431+
432+
const compacted = await compactBlockOutput({ childTraceSpans: [nestedWorkflowSpan] }, options)
433+
434+
const [nested] = compacted.childTraceSpans as TraceSpan[]
435+
expect(nested.output?.result).toBeUndefined()
436+
expectSpanTree(nested.output?.childTraceSpans)
437+
})
438+
407439
it('drops a child span tree whose skeleton alone exceeds the threshold', async () => {
408440
const spans = Array.from({ length: 64 }, (_, index) => ({
409441
...spansTooLargeAsAWhole()[0],

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

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -274,28 +274,28 @@ async function mapEntriesAsync(
274274

275275
/**
276276
* Compacts a trace span tree without collapsing its structure: `children` and
277-
* `output.childTraceSpans` stay arrays and only each span's payload fields
278-
* spill when oversized. See {@link compactChildTraceSpans} for the size bound.
277+
* `output.childTraceSpans` stay arrays of spans and only each span's payload
278+
* fields spill when oversized. A malformed list or entry is dropped, so every
279+
* reader can walk the tree. See {@link compactChildTraceSpans} for the size bound.
279280
*/
280281
async function compactTraceSpanTree(
281282
spans: unknown,
282283
options: CompactExecutionPayloadOptions,
283284
seen: WeakSet<object>
284-
): Promise<unknown> {
285+
): Promise<unknown[] | undefined> {
285286
if (!Array.isArray(spans)) {
286-
return compactExecutionPayload(spans, options)
287+
return undefined
287288
}
288-
return Promise.all(spans.map((span) => compactTraceSpan(span, options, seen)))
289+
return Promise.all(
290+
spans.filter(isRecordLike).map((span) => compactTraceSpan(span, options, seen))
291+
)
289292
}
290293

291294
async function compactTraceSpan(
292-
span: unknown,
295+
span: Record<string, unknown>,
293296
options: CompactExecutionPayloadOptions,
294297
seen: WeakSet<object>
295-
): Promise<unknown> {
296-
if (!isRecordLike(span)) {
297-
return compactExecutionPayload(span, options)
298-
}
298+
): Promise<Record<string, unknown>> {
299299
if (seen.has(span)) {
300300
return span
301301
}

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isRecordLike } from '@sim/utils/object'
12
import type { TraceSpan } from '@/lib/logs/types'
23

34
export function stripModelToolCallArguments(
@@ -35,7 +36,8 @@ export function stripProviderTimingContent(
3536
/**
3637
* A trace span tree with every span's content removed: inputs, outputs,
3738
* thinking, error text, tool-call arguments, and provider content. Keeps the
38-
* tree's shape, names, timing, status, and cost.
39+
* tree's shape, names, timing, status, and cost, including a nested child
40+
* workflow's spans carried on `output.childTraceSpans`.
3941
*/
4042
export function summarizeTraceSpansWithoutIo(traceSpans?: TraceSpan[]): TraceSpan[] | undefined {
4143
if (!traceSpans) {
@@ -45,7 +47,7 @@ export function summarizeTraceSpansWithoutIo(traceSpans?: TraceSpan[]): TraceSpa
4547
return traceSpans.map((span) => {
4648
const {
4749
input: _input,
48-
output: _output,
50+
output,
4951
children,
5052
thinking: _thinking,
5153
errorMessage: _errorMessage,
@@ -54,8 +56,12 @@ export function summarizeTraceSpansWithoutIo(traceSpans?: TraceSpan[]): TraceSpa
5456
providerTiming,
5557
...rest
5658
} = span
59+
const nestedSpans = isRecordLike(output) ? output.childTraceSpans : undefined
5760
return {
5861
...rest,
62+
...(Array.isArray(nestedSpans) && nestedSpans.length
63+
? { output: { childTraceSpans: summarizeTraceSpansWithoutIo(nestedSpans) } }
64+
: {}),
5965
...(modelToolCalls ? { modelToolCalls: stripModelToolCallArguments(modelToolCalls) } : {}),
6066
...(toolCalls ? { toolCalls: stripLegacyToolCallContent(toolCalls) } : {}),
6167
...(providerTiming ? { providerTiming: stripProviderTimingContent(providerTiming) } : {}),

0 commit comments

Comments
 (0)