Skip to content

Commit 128c6aa

Browse files
committed
fix(logs): keep checked trace content when the display projection budget runs out
- Project each log display value on its own, so one oversized or ref-backed value withholds only itself instead of the error, message and every other envelope field - When the whole-trace content walk exhausts its node budget, keep the spans that already passed it and withhold only the rest; every other projection failure still withholds the whole trace - Put the bulky block dumps after the trace spans so budget pressure drops them first - Attribute projection warnings to their execution, workflow, workspace, mode, span and field
1 parent f0cfdfa commit 128c6aa

4 files changed

Lines changed: 332 additions & 59 deletions

File tree

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

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,6 +1019,13 @@ describe('projectTraceSpansForSecrets', () => {
10191019
}
10201020

10211021
expect(warnMock).toHaveBeenCalledWith('Omitting trace content that could not be sanitized', {
1022+
executionId: 'execution-1',
1023+
workflowId: 'workflow-1',
1024+
workspaceId: 'workspace-1',
1025+
readOnly: false,
1026+
spanId: 'span-1',
1027+
spanType: 'function',
1028+
field: 'output',
10221029
failure: { name: 'SyntaxError' },
10231030
})
10241031
})
@@ -1048,3 +1055,96 @@ describe('projectTraceSpansForSecrets', () => {
10481055
expect(cursor?.children).toEqual([])
10491056
})
10501057
})
1058+
1059+
describe('projectTraceSpansForSecrets whole-trace content budget', () => {
1060+
const SECRET_REGISTRY = createRegistry([
1061+
{ plaintext: 'top-secret', replacement: '{{API_SECRET}}' },
1062+
])
1063+
1064+
/** A span whose output alone fits the content budget but two-and-a-half of which do not. */
1065+
function heavySpan(id: string, marker: string, overrides: Partial<TraceSpan> = {}): TraceSpan {
1066+
return createSpan({
1067+
id,
1068+
output: { marker, items: Array.from({ length: 40_000 }, (_, index) => `row-${index}`) },
1069+
...overrides,
1070+
})
1071+
}
1072+
1073+
it('keeps the spans checked before the budget ran out and withholds the rest', async () => {
1074+
const projected = await projectTraceSpansForSecrets(
1075+
[
1076+
heavySpan('span-a', 'first top-secret'),
1077+
heavySpan('span-b', 'second'),
1078+
heavySpan('span-c', 'third top-secret'),
1079+
createSpan({ id: 'span-d', output: { marker: 'fourth top-secret' } }),
1080+
],
1081+
{ registry: SECRET_REGISTRY, store: STORE, allowLargeValueWrites: false }
1082+
)
1083+
1084+
expect(projected.map((span) => span.id)).toEqual(['span-a', 'span-b', 'span-c', 'span-d'])
1085+
expect(projected[0].output?.marker).toBe('first {{API_SECRET}}')
1086+
expect(projected[0]).not.toHaveProperty('contentWithheld')
1087+
expect(projected[1].output?.marker).toBe('second')
1088+
for (const withheld of projected.slice(2)) {
1089+
expect(withheld).not.toHaveProperty('output')
1090+
expect(withheld.contentWithheld).toBe(true)
1091+
}
1092+
expect(JSON.stringify(projected)).not.toContain('top-secret')
1093+
expect(warnMock).toHaveBeenCalledWith(
1094+
'Trace content exceeded the projection budget; withholding the remaining spans',
1095+
expect.objectContaining({
1096+
executionId: 'execution-1',
1097+
readOnly: true,
1098+
retainedSpanCount: 2,
1099+
withheldSpanCount: 2,
1100+
})
1101+
)
1102+
})
1103+
1104+
it('still withholds every span when a later span fails for any reason but the budget', async () => {
1105+
const projected = await projectTraceSpansForSecrets(
1106+
[
1107+
createSpan({ id: 'span-a', output: { marker: 'first' } }),
1108+
createSpan({
1109+
id: 'span-b',
1110+
toolCalls: Array.from({ length: 100_001 }, (_, index) => ({
1111+
name: `tool-${index}`,
1112+
duration: 0,
1113+
startTime: '2026-07-01T00:00:00.000Z',
1114+
endTime: '2026-07-01T00:00:00.000Z',
1115+
status: 'success',
1116+
})),
1117+
}),
1118+
],
1119+
{ registry: SECRET_REGISTRY, store: STORE, allowLargeValueWrites: false }
1120+
)
1121+
1122+
expect(projected[0]).not.toHaveProperty('output')
1123+
expect(projected[0].contentWithheld).toBe(true)
1124+
expect(warnMock).toHaveBeenCalledWith(
1125+
'Trace secret projection failed; retaining structural spans only',
1126+
expect.objectContaining({ executionId: 'execution-1', readOnly: true })
1127+
)
1128+
})
1129+
1130+
it('withholds the whole top-level span whose descendant crossed the budget', async () => {
1131+
const projected = await projectTraceSpansForSecrets(
1132+
[
1133+
heavySpan('span-a', 'first'),
1134+
createSpan({
1135+
id: 'span-b',
1136+
output: { marker: 'parent top-secret' },
1137+
children: [heavySpan('child-1', 'child one'), heavySpan('child-2', 'child two')],
1138+
}),
1139+
],
1140+
{ registry: SECRET_REGISTRY, store: STORE, allowLargeValueWrites: false }
1141+
)
1142+
1143+
expect(projected[0].output?.marker).toBe('first')
1144+
expect(projected[1]).not.toHaveProperty('output')
1145+
expect(projected[1].contentWithheld).toBe(true)
1146+
expect(projected[1].children?.map((child) => child.id)).toEqual(['child-1', 'child-2'])
1147+
expect(projected[1].children?.every((child) => !Object.hasOwn(child, 'output'))).toBe(true)
1148+
expect(JSON.stringify(projected)).not.toContain('top-secret')
1149+
})
1150+
})

‎apps/sim/lib/logs/execution/trace-secret-projection.ts‎

Lines changed: 101 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { toError } from '@sim/utils/errors'
3-
import { isPlainRecord, omit } from '@sim/utils/object'
3+
import { filterUndefined, isPlainRecord, omit } from '@sim/utils/object'
44
import { yieldWhenSliceExpires } from '@/lib/core/utils/event-loop'
55
import {
66
isLargeArrayManifest,
@@ -84,6 +84,7 @@ interface ProjectionContext {
8484
storedLargeValueCount: number
8585
/** Content fields omitted so far; a span compares it across its own fields to set `contentWithheld`. */
8686
omittedContentFields: number
87+
logDetails: ProjectionLogDetails
8788
}
8889

8990
interface OversizedHydrationGate {
@@ -132,6 +133,40 @@ class TraceSecretProjectionError extends Error {
132133
}
133134
}
134135

136+
/**
137+
* The content walk ran out of its node budget. Unlike every other projection failure, this says
138+
* nothing about the content already checked: it only bounds how much more is walked.
139+
*/
140+
class TraceContentBudgetError extends TraceSecretProjectionError {}
141+
142+
/** Identifies the run behind a projection warning; every field is a stored id or a mode flag. */
143+
interface ProjectionLogDetails {
144+
executionId?: string
145+
workflowId?: string
146+
workspaceId?: string
147+
readOnly: boolean
148+
}
149+
150+
function projectionLogDetails(
151+
store: LargeValueStoreContext,
152+
readOnly: boolean
153+
): ProjectionLogDetails {
154+
return {
155+
...filterUndefined({
156+
executionId: store.executionId,
157+
workflowId: store.workflowId,
158+
workspaceId: store.workspaceId,
159+
}),
160+
readOnly,
161+
}
162+
}
163+
164+
/** The span whose content field a per-field warning names. */
165+
interface ContentFieldOwner {
166+
id: string
167+
type: string
168+
}
169+
135170
/**
136171
* Diagnostic payload for a projection fallback.
137172
*
@@ -168,13 +203,14 @@ function createProjectionContext(
168203
sourceLargeValueBytes: 0,
169204
storedLargeValueBytes: 0,
170205
storedLargeValueCount: 0,
206+
logDetails: projectionLogDetails(store, !allowLargeValueWrites),
171207
}
172208
}
173209

174210
function visitNode(state: TraversalState, depth: number): void {
175211
state.nodes += 1
176212
if (state.nodes > MAX_CONTENT_NODES) {
177-
throw new TraceSecretProjectionError('Trace content node limit exceeded')
213+
throw new TraceContentBudgetError('Trace content node limit exceeded')
178214
}
179215
if (depth > MAX_CONTENT_DEPTH) {
180216
throw new TraceSecretProjectionError('Trace content depth limit exceeded')
@@ -183,7 +219,7 @@ function visitNode(state: TraversalState, depth: number): void {
183219

184220
function assertArrayFitsTraversal(value: readonly unknown[], state: TraversalState): void {
185221
if (value.length > MAX_CONTENT_NODES - state.nodes) {
186-
throw new TraceSecretProjectionError('Trace content array exceeds the traversal limit')
222+
throw new TraceContentBudgetError('Trace content array exceeds the traversal limit')
187223
}
188224
}
189225

@@ -802,13 +838,19 @@ async function replaceLargeValues(
802838

803839
async function sanitizeContentField(
804840
value: unknown,
805-
context: ProjectionContext
841+
context: ProjectionContext,
842+
owner: ContentFieldOwner,
843+
field: string
806844
): Promise<unknown | typeof OMIT> {
807845
await yieldWhenSliceExpires()
808846
try {
809847
return await sanitizeMaterializedValue(value, context)
810848
} catch (error) {
811849
logger.warn('Omitting trace content that could not be sanitized', {
850+
...context.logDetails,
851+
spanId: owner.id,
852+
spanType: owner.type,
853+
field,
812854
failure: describeProjectionFailure(error),
813855
})
814856
context.omittedContentFields += 1
@@ -829,6 +871,8 @@ function assertTraceStructureArrayFits(
829871
async function sanitizeIterationToolCalls(
830872
calls: IterationToolCall[],
831873
context: ProjectionContext,
874+
owner: ContentFieldOwner,
875+
field: string,
832876
state: BoundedTraceStructureState,
833877
depth: number
834878
): Promise<IterationToolCall[]> {
@@ -839,7 +883,7 @@ async function sanitizeIterationToolCalls(
839883
if (!takeTraceStructureNode(state, depth)) {
840884
throw new TraceSecretProjectionError('Trace tool-call structure limit exceeded')
841885
}
842-
const args = await sanitizeContentField(call.arguments, context)
886+
const args = await sanitizeContentField(call.arguments, context, owner, `${field}.arguments`)
843887
sanitized.push(
844888
args === OMIT
845889
? (omit(call, ['arguments']) as IterationToolCall)
@@ -852,6 +896,7 @@ async function sanitizeIterationToolCalls(
852896
async function sanitizeLegacyToolCalls(
853897
calls: ToolCall[],
854898
context: ProjectionContext,
899+
owner: ContentFieldOwner,
855900
state: BoundedTraceStructureState,
856901
depth: number
857902
): Promise<ToolCall[]> {
@@ -864,17 +909,17 @@ async function sanitizeLegacyToolCalls(
864909
}
865910
let projected: ToolCall = { ...call }
866911
if (call.input !== undefined) {
867-
const input = await sanitizeContentField(call.input, context)
912+
const input = await sanitizeContentField(call.input, context, owner, 'toolCalls.input')
868913
if (input === OMIT) projected = omit(projected, ['input']) as ToolCall
869914
else projected.input = input as Record<string, unknown>
870915
}
871916
if (call.output !== undefined) {
872-
const output = await sanitizeContentField(call.output, context)
917+
const output = await sanitizeContentField(call.output, context, owner, 'toolCalls.output')
873918
if (output === OMIT) projected = omit(projected, ['output']) as ToolCall
874919
else projected.output = output as Record<string, unknown>
875920
}
876921
if (call.error !== undefined) {
877-
const error = await sanitizeContentField(call.error, context)
922+
const error = await sanitizeContentField(call.error, context, owner, 'toolCalls.error')
878923
if (error === OMIT) projected = omit(projected, ['error']) as ToolCall
879924
else projected.error = error as string
880925
}
@@ -886,6 +931,7 @@ async function sanitizeLegacyToolCalls(
886931
async function sanitizeProviderSegment(
887932
segment: ProviderTimingSegment,
888933
context: ProjectionContext,
934+
owner: ContentFieldOwner,
889935
state: BoundedTraceStructureState,
890936
depth: number
891937
): Promise<ProviderTimingSegment> {
@@ -898,7 +944,12 @@ async function sanitizeProviderSegment(
898944
for (const key of ['assistantContent', 'thinkingContent', 'errorMessage'] as const) {
899945
const value = segment[key]
900946
if (value === undefined) continue
901-
const sanitized = await sanitizeContentField(value, context)
947+
const sanitized = await sanitizeContentField(
948+
value,
949+
context,
950+
owner,
951+
`providerTiming.segments.${key}`
952+
)
902953
if (sanitized === OMIT) {
903954
projected = omit(projected, [key]) as ProviderTimingSegment
904955
} else {
@@ -910,6 +961,8 @@ async function sanitizeProviderSegment(
910961
projected.toolCalls = await sanitizeIterationToolCalls(
911962
segment.toolCalls,
912963
context,
964+
owner,
965+
'providerTiming.segments.toolCalls',
913966
state,
914967
depth + 1
915968
)
@@ -936,7 +989,7 @@ async function sanitizeTraceSpan(
936989
for (const key of ['input', 'output', 'thinking', 'errorMessage'] as const) {
937990
const value = span[key]
938991
if (value === undefined) continue
939-
const sanitized = await sanitizeContentField(value, context)
992+
const sanitized = await sanitizeContentField(value, context, span, key)
940993
if (sanitized === OMIT) {
941994
projected = omit(projected, [key]) as TraceSpan
942995
} else {
@@ -948,20 +1001,28 @@ async function sanitizeTraceSpan(
9481001
projected.modelToolCalls = await sanitizeIterationToolCalls(
9491002
span.modelToolCalls,
9501003
context,
1004+
span,
1005+
'modelToolCalls',
9511006
state,
9521007
depth + 1
9531008
)
9541009
}
9551010

9561011
if (span.toolCalls !== undefined) {
957-
projected.toolCalls = await sanitizeLegacyToolCalls(span.toolCalls, context, state, depth + 1)
1012+
projected.toolCalls = await sanitizeLegacyToolCalls(
1013+
span.toolCalls,
1014+
context,
1015+
span,
1016+
state,
1017+
depth + 1
1018+
)
9581019
}
9591020

9601021
if (span.providerTiming !== undefined) {
9611022
assertTraceStructureArrayFits(span.providerTiming.segments, state)
9621023
const segments: ProviderTimingSegment[] = []
9631024
for (const segment of span.providerTiming.segments) {
964-
segments.push(await sanitizeProviderSegment(segment, context, state, depth + 1))
1025+
segments.push(await sanitizeProviderSegment(segment, context, span, state, depth + 1))
9651026
}
9661027
projected.providerTiming = { ...span.providerTiming, segments }
9671028
}
@@ -1582,6 +1643,7 @@ export async function enforceTraceSpanSecretInvariant(
15821643
return traceSpans
15831644
} catch (error) {
15841645
logger.warn('Trace secret invariant failed; retaining structural spans only', {
1646+
...projectionLogDetails(options.store, false),
15851647
failure: describeProjectionFailure(error),
15861648
})
15871649
return structuralOnlyTraceSpans(traceSpans)
@@ -1600,28 +1662,48 @@ export async function projectTraceSpansForSecrets(
16001662
return structuralOnlyTraceSpans(traceSpans)
16011663
}
16021664

1665+
const allowLargeValueWrites = options.allowLargeValueWrites !== false
1666+
const logDetails = projectionLogDetails(options.store, !allowLargeValueWrites)
16031667
try {
16041668
const matcher = createResolvedSecretMatcher(options.registry.getActiveMatches(), {
16051669
preserveNamedProvenanceLabels: true,
16061670
})
16071671
if (!matcher) return cloneTraceSpansForProjection(traceSpans)
16081672

1609-
const context = createProjectionContext(
1610-
matcher,
1611-
options.store,
1612-
options.allowLargeValueWrites !== false
1613-
)
1673+
const context = createProjectionContext(matcher, options.store, allowLargeValueWrites)
16141674

16151675
const projected: TraceSpan[] = []
16161676
const state: BoundedTraceStructureState = { nodes: 0, truncated: false }
16171677
assertTraceStructureArrayFits(traceSpans, state)
16181678
const assertContentIsSafe = createSpanContentAssertion(traceSpans, context)
1619-
for (const span of traceSpans) {
1620-
projected.push(await sanitizeTraceSpan(span, context, 0, state, assertContentIsSafe))
1679+
for (let index = 0; index < traceSpans.length; index += 1) {
1680+
try {
1681+
projected.push(
1682+
await sanitizeTraceSpan(traceSpans[index], context, 0, state, assertContentIsSafe)
1683+
)
1684+
} catch (error) {
1685+
if (!(error instanceof TraceContentBudgetError)) throw error
1686+
/**
1687+
* Every span already returned was sanitized and then passed the whole-trace check,
1688+
* descendants included, so only the span that crossed the budget and those after it
1689+
* lose their content. The walk stops here, so the budget still bounds it.
1690+
*/
1691+
logger.warn(
1692+
'Trace content exceeded the projection budget; withholding the remaining spans',
1693+
{
1694+
...logDetails,
1695+
retainedSpanCount: index,
1696+
withheldSpanCount: traceSpans.length - index,
1697+
failure: describeProjectionFailure(error),
1698+
}
1699+
)
1700+
return [...projected, ...structuralOnlyTraceSpans(traceSpans.slice(index))]
1701+
}
16211702
}
16221703
return projected
16231704
} catch (error) {
16241705
logger.warn('Trace secret projection failed; retaining structural spans only', {
1706+
...logDetails,
16251707
failure: describeProjectionFailure(error),
16261708
})
16271709
return structuralOnlyTraceSpans(traceSpans)

0 commit comments

Comments
 (0)