Skip to content

Commit 5ce7f18

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

4 files changed

Lines changed: 116 additions & 71 deletions

File tree

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -392,13 +392,26 @@ describe('compacting span trees', () => {
392392
expect(isLargeValueRef(spilled?.output?.result)).toBe(true)
393393
})
394394

395-
it('drops a block output child span tree too large as a whole', async () => {
395+
it('keeps the skeleton of a block output child span tree too large as a whole', async () => {
396396
const compacted = await compactBlockOutput(
397397
{ result: 'done', childTraceSpans: spansTooLargeAsAWhole() },
398398
options
399399
)
400400

401401
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 a child span tree whose skeleton alone exceeds the threshold', async () => {
408+
const spans = Array.from({ length: 64 }, (_, index) => ({
409+
...spansTooLargeAsAWhole()[0],
410+
id: `loop-${index}`,
411+
}))
412+
413+
const compacted = await compactBlockOutput({ childTraceSpans: spans }, options)
414+
402415
expect(compacted.childTraceSpans).toBeUndefined()
403416
})
404417

@@ -425,7 +438,7 @@ describe('compacting span trees', () => {
425438
expectSpanTree(compacted.childTraceSpans)
426439
})
427440

428-
it('keeps block log child span trees whole or drops them', async () => {
441+
it('keeps block log child span trees whole or as a skeleton', async () => {
429442
const compacted =
430443
(await compactBlockLogs(
431444
[
@@ -436,7 +449,9 @@ describe('compacting span trees', () => {
436449
)) ?? []
437450

438451
expectSpanTree(compacted[0]?.childTraceSpans)
439-
expect(compacted[1]?.childTraceSpans).toBeUndefined()
452+
expectSpanTree(compacted[1]?.childTraceSpans)
453+
const [loop] = compacted[1]?.childTraceSpans ?? []
454+
expect(loop.children?.[0].children?.[0].output).toBeUndefined()
440455
})
441456

442457
it('keeps a nested child workflow span tree shaped as a tree', async () => {

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

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
LARGE_VALUE_THRESHOLD_BYTES,
1212
} from '@/lib/execution/payloads/large-value-ref'
1313
import { type LargeValueStoreContext, storeLargeValue } from '@/lib/execution/payloads/store'
14+
import { summarizeTraceSpansWithoutIo } from '@/lib/logs/execution/trace-spans/summarize'
1415
import type { TraceSpan } from '@/lib/logs/types'
1516
import type { BlockLog } from '@/executor/types'
1617

@@ -328,9 +329,10 @@ async function compactSpanOutput(
328329

329330
/**
330331
* Compacts a block's child span tree for its log. Readers walk span trees as
331-
* arrays, so a tree is kept whole or not at all: payload fields spill
332-
* individually (see {@link compactTraceSpanTree}), and a tree still over the
333-
* threshold as a whole is dropped, bounding it as generic compaction did.
332+
* arrays, so a tree is never collapsed: payload fields spill individually (see
333+
* {@link compactTraceSpanTree}). A tree still over the threshold as a whole
334+
* keeps only its skeleton (shape, names, timing, status, cost), and one whose
335+
* skeleton is still over it is dropped, bounding it as generic compaction did.
334336
*/
335337
async function compactChildTraceSpans(
336338
spans: unknown,
@@ -342,17 +344,34 @@ async function compactChildTraceSpans(
342344
}
343345
return undefined
344346
}
345-
const compacted = await compactTraceSpanTree(spans, options, new WeakSet<object>())
346-
const measured = getJsonAndSize(compacted)
347+
const compacted = (await compactTraceSpanTree(
348+
spans,
349+
options,
350+
new WeakSet<object>()
351+
)) as TraceSpan[]
347352
const maxBytes = options.thresholdBytes ?? LARGE_VALUE_THRESHOLD_BYTES
348-
if (measured && measured.size <= maxBytes) {
349-
return compacted as TraceSpan[]
353+
const measured = getJsonAndSize(compacted)
354+
if (!measured) {
355+
logger.warn('Dropping child trace spans that cannot be serialized')
356+
return undefined
350357
}
351-
if (measured && options.rejectLargeValues) {
358+
if (measured.size <= maxBytes) {
359+
return compacted
360+
}
361+
if (options.rejectLargeValues) {
352362
throw largeValueLimitError(options, measured.size)
353363
}
364+
const skeleton = summarizeTraceSpansWithoutIo(compacted)
365+
const skeletonSize = getJsonAndSize(skeleton)?.size
366+
if (skeletonSize !== undefined && skeletonSize <= maxBytes) {
367+
logger.warn('Kept only the skeleton of child trace spans too large to keep whole', {
368+
observedBytes: measured.size,
369+
maxBytes,
370+
})
371+
return skeleton
372+
}
354373
logger.warn('Dropping child trace spans too large to keep', {
355-
observedBytes: measured?.size,
374+
observedBytes: skeletonSize ?? measured.size,
356375
maxBytes,
357376
})
358377
return undefined

‎apps/sim/lib/logs/execution/logger.ts‎

Lines changed: 5 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ import {
5050
} from '@/lib/logs/execution/progress-markers'
5151
import { snapshotService } from '@/lib/logs/execution/snapshot/service'
5252
import { traceSpansHaveHandledErrors } from '@/lib/logs/execution/trace-spans/handled-errors'
53+
import {
54+
stripLegacyToolCallContent,
55+
stripModelToolCallArguments,
56+
summarizeTraceSpansWithoutIo,
57+
} from '@/lib/logs/execution/trace-spans/summarize'
5358
import { traceSpansIndicateFailure } from '@/lib/logs/execution/trace-spans/trace-spans'
5459
import {
5560
copyTraceSpansWithoutCosts,
@@ -195,12 +200,6 @@ function retainBoundedTraceContent<T>(value: T, maxBytes = MAX_TRACE_IO_BYTES):
195200
return size !== undefined && size <= maxBytes ? value : undefined
196201
}
197202

198-
function stripModelToolCallArguments(
199-
calls: NonNullable<TraceSpan['modelToolCalls']>
200-
): NonNullable<TraceSpan['modelToolCalls']> {
201-
return calls.map(({ arguments: _arguments, ...call }) => call as (typeof calls)[number])
202-
}
203-
204203
function compactModelToolCalls(
205204
calls: NonNullable<TraceSpan['modelToolCalls']>
206205
): NonNullable<TraceSpan['modelToolCalls']> | undefined {
@@ -226,12 +225,6 @@ function compactLegacyToolCalls(
226225
return retainBoundedTraceContent(compacted)
227226
}
228227

229-
function stripLegacyToolCallContent(
230-
calls: NonNullable<TraceSpan['toolCalls']>
231-
): NonNullable<TraceSpan['toolCalls']> {
232-
return calls.map(({ input: _input, output: _output, error: _error, ...call }) => call)
233-
}
234-
235228
function compactProviderTiming(
236229
providerTiming: NonNullable<TraceSpan['providerTiming']>
237230
): NonNullable<TraceSpan['providerTiming']> {
@@ -253,26 +246,6 @@ function compactProviderTiming(
253246
}
254247
}
255248

256-
function stripProviderTimingContent(
257-
providerTiming: NonNullable<TraceSpan['providerTiming']>
258-
): NonNullable<TraceSpan['providerTiming']> {
259-
return {
260-
...providerTiming,
261-
segments: providerTiming.segments.map(
262-
({
263-
assistantContent: _assistantContent,
264-
thinkingContent: _thinkingContent,
265-
errorMessage: _errorMessage,
266-
toolCalls,
267-
...segment
268-
}) => ({
269-
...segment,
270-
...(toolCalls ? { toolCalls: stripModelToolCallArguments(toolCalls) } : {}),
271-
})
272-
),
273-
}
274-
}
275-
276249
function summarizeTraceSpansForExecutionData(traceSpans?: TraceSpan[]): TraceSpan[] | undefined {
277250
if (!traceSpans) {
278251
return traceSpans
@@ -317,33 +290,6 @@ function summarizeTraceSpansForExecutionData(traceSpans?: TraceSpan[]): TraceSpa
317290
})
318291
}
319292

320-
function summarizeTraceSpansWithoutIo(traceSpans?: TraceSpan[]): TraceSpan[] | undefined {
321-
if (!traceSpans) {
322-
return traceSpans
323-
}
324-
325-
return traceSpans.map((span) => {
326-
const {
327-
input: _input,
328-
output: _output,
329-
children,
330-
thinking: _thinking,
331-
errorMessage: _errorMessage,
332-
modelToolCalls,
333-
toolCalls,
334-
providerTiming,
335-
...rest
336-
} = span
337-
return {
338-
...rest,
339-
...(modelToolCalls ? { modelToolCalls: stripModelToolCallArguments(modelToolCalls) } : {}),
340-
...(toolCalls ? { toolCalls: stripLegacyToolCallContent(toolCalls) } : {}),
341-
...(providerTiming ? { providerTiming: stripProviderTimingContent(providerTiming) } : {}),
342-
...(children?.length ? { children: summarizeTraceSpansWithoutIo(children) } : {}),
343-
}
344-
})
345-
}
346-
347293
function summarizeExecutionState(executionState?: SerializableExecutionState) {
348294
if (!executionState) {
349295
return undefined
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { TraceSpan } from '@/lib/logs/types'
2+
3+
export function stripModelToolCallArguments(
4+
calls: NonNullable<TraceSpan['modelToolCalls']>
5+
): NonNullable<TraceSpan['modelToolCalls']> {
6+
return calls.map(({ arguments: _arguments, ...call }) => call as (typeof calls)[number])
7+
}
8+
9+
export function stripLegacyToolCallContent(
10+
calls: NonNullable<TraceSpan['toolCalls']>
11+
): NonNullable<TraceSpan['toolCalls']> {
12+
return calls.map(({ input: _input, output: _output, error: _error, ...call }) => call)
13+
}
14+
15+
export function stripProviderTimingContent(
16+
providerTiming: NonNullable<TraceSpan['providerTiming']>
17+
): NonNullable<TraceSpan['providerTiming']> {
18+
return {
19+
...providerTiming,
20+
segments: providerTiming.segments.map(
21+
({
22+
assistantContent: _assistantContent,
23+
thinkingContent: _thinkingContent,
24+
errorMessage: _errorMessage,
25+
toolCalls,
26+
...segment
27+
}) => ({
28+
...segment,
29+
...(toolCalls ? { toolCalls: stripModelToolCallArguments(toolCalls) } : {}),
30+
})
31+
),
32+
}
33+
}
34+
35+
/**
36+
* A trace span tree with every span's content removed: inputs, outputs,
37+
* thinking, error text, tool-call arguments, and provider content. Keeps the
38+
* tree's shape, names, timing, status, and cost.
39+
*/
40+
export function summarizeTraceSpansWithoutIo(traceSpans?: TraceSpan[]): TraceSpan[] | undefined {
41+
if (!traceSpans) {
42+
return traceSpans
43+
}
44+
45+
return traceSpans.map((span) => {
46+
const {
47+
input: _input,
48+
output: _output,
49+
children,
50+
thinking: _thinking,
51+
errorMessage: _errorMessage,
52+
modelToolCalls,
53+
toolCalls,
54+
providerTiming,
55+
...rest
56+
} = span
57+
return {
58+
...rest,
59+
...(modelToolCalls ? { modelToolCalls: stripModelToolCallArguments(modelToolCalls) } : {}),
60+
...(toolCalls ? { toolCalls: stripLegacyToolCallContent(toolCalls) } : {}),
61+
...(providerTiming ? { providerTiming: stripProviderTimingContent(providerTiming) } : {}),
62+
...(children?.length ? { children: summarizeTraceSpansWithoutIo(children) } : {}),
63+
}
64+
})
65+
}

0 commit comments

Comments
 (0)