Skip to content

Commit 053c933

Browse files
authored
fix(logs): mark withheld trace span content so logs query stops reporting it as missing (#9314)
- Secret projection sets `contentWithheld: true` on a span when it omits any of its content (input/output/thinking/errorMessage, tool-call or provider-segment content) and on every span of a structural-only fallback; marker only, no content restored - v2 `LogTraceSpan` contract documents the optional marker (additive); regenerated openapi-v2-logs.json - `logs query` reports a withheld note and a `contentWithheld` count instead of "path not found on span"; under --where, withheld runs are kept (with --field still resolved) instead of silently filtered out
1 parent 0847b21 commit 053c933

8 files changed

Lines changed: 200 additions & 8 deletions

File tree

‎apps/docs/openapi-v2-logs.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1448,6 +1448,11 @@
14481448
"type": "number",
14491449
"description": "Offset from the root span in milliseconds."
14501450
},
1451+
"contentWithheld": {
1452+
"type": "boolean",
1453+
"const": true,
1454+
"description": "Present when the log projection withheld some or all of this span's content (input, output, thinking, error message, tool-call or provider-segment content) because it could not be proven safe to display. Status, timing, and usage remain. A field missing from a span carrying this marker may exist on the run but was not returned."
1455+
},
14511456
"toolCalls": {
14521457
"type": "array",
14531458
"items": {

‎apps/sim/lib/api/contracts/logs.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ export type LogTraceSpan = {
218218
tokens?: number | { total?: number; input?: number; output?: number }
219219
cost?: { total?: number; input?: number; output?: number; toolCost?: number }
220220
relativeStartMs?: number
221+
contentWithheld?: true
221222
toolCalls?: Array<z.output<typeof toolCallSchema>>
222223
children?: LogTraceSpan[]
223224
}
@@ -273,6 +274,12 @@ export const traceSpanSchema: z.ZodType<LogTraceSpan> = z
273274
.number()
274275
.describe('Offset from the root span in milliseconds.')
275276
.optional(),
277+
contentWithheld: z
278+
.literal(true)
279+
.describe(
280+
"Present when the log projection withheld some or all of this span's content (input, output, thinking, error message, tool-call or provider-segment content) because it could not be proven safe to display. Status, timing, and usage remain. A field missing from a span carrying this marker may exist on the run but was not returned."
281+
)
282+
.optional(),
276283
toolCalls: z.array(toolCallSchema).describe('Tool calls recorded by the span.').optional(),
277284
children: z.array(traceSpanSchema).describe('Nested child trace spans.').optional(),
278285
})

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,85 @@ describe('projectTraceSpansForSecrets', () => {
502502
expect(span.children?.[0]).not.toHaveProperty('output')
503503
})
504504

505+
it('marks every span of a structural-only fallback as content-withheld', async () => {
506+
const source = [
507+
createSpan({
508+
output: { secret: 'raw' },
509+
children: [createSpan({ id: 'child-1', output: { secret: 'raw' } })],
510+
}),
511+
]
512+
513+
const incomplete = await projectTraceSpansForSecrets(source, {
514+
registry: createRegistry([], false),
515+
store: STORE,
516+
})
517+
const failedMatcher = await projectTraceSpansForSecrets(source, {
518+
registry: createRegistry([
519+
{ plaintext: 's'.repeat(64 * 1024 + 1), replacement: '{{TOO_LARGE}}' },
520+
]),
521+
store: STORE,
522+
})
523+
const failedInvariant = await enforceTraceSpanSecretInvariant(
524+
[createSpan({ output: { apiKey: '[REDACTED]' } })],
525+
{
526+
registry: createRegistry([{ plaintext: 'REDACTED', replacement: '{{X}}' }]),
527+
store: STORE,
528+
}
529+
)
530+
531+
for (const [span] of [incomplete, failedMatcher]) {
532+
expect(span.contentWithheld).toBe(true)
533+
expect(span.children?.[0].contentWithheld).toBe(true)
534+
}
535+
expect(failedInvariant[0].contentWithheld).toBe(true)
536+
})
537+
538+
it('marks a span whose content field was omitted and leaves fully projected spans unmarked', async () => {
539+
const [omitted, intact] = await projectTraceSpansForSecrets(
540+
[
541+
createSpan({ input: { safe: 'keep' }, output: { secretval: 1, '{{TOKEN}}': 2 } }),
542+
createSpan({ id: 'span-2', output: { value: 'secretval' } }),
543+
],
544+
{
545+
registry: createRegistry([{ plaintext: 'secretval', replacement: '{{TOKEN}}' }]),
546+
store: STORE,
547+
}
548+
)
549+
550+
expect(omitted.input).toEqual({ safe: 'keep' })
551+
expect(omitted.contentWithheld).toBe(true)
552+
expect(intact.output).toEqual({ value: '{{TOKEN}}' })
553+
expect(intact).not.toHaveProperty('contentWithheld')
554+
})
555+
556+
it('marks a span whose tool-call content was omitted', async () => {
557+
const [span] = await projectTraceSpansForSecrets(
558+
[
559+
createSpan({
560+
output: { safe: 'keep' },
561+
toolCalls: [
562+
{
563+
name: 'legacy',
564+
duration: 1,
565+
startTime: '2026-07-01T00:00:00.000Z',
566+
endTime: '2026-07-01T00:00:00.001Z',
567+
status: 'success',
568+
output: { secretval: 1, '{{TOKEN}}': 2 },
569+
},
570+
],
571+
}),
572+
],
573+
{
574+
registry: createRegistry([{ plaintext: 'secretval', replacement: '{{TOKEN}}' }]),
575+
store: STORE,
576+
}
577+
)
578+
579+
expect(span.output).toEqual({ safe: 'keep' })
580+
expect(span.toolCalls?.[0]).not.toHaveProperty('output')
581+
expect(span.contentWithheld).toBe(true)
582+
})
583+
505584
it('fails closed when a later log transform reintroduces an active literal', async () => {
506585
const source = [createSpan({ output: { apiKey: '[REDACTED]' } })]
507586

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ interface ProjectionContext {
8282
sourceLargeValueBytes: number
8383
storedLargeValueBytes: number
8484
storedLargeValueCount: number
85+
/** Content fields omitted so far; a span compares it across its own fields to set `contentWithheld`. */
86+
omittedContentFields: number
8587
}
8688

8789
interface OversizedHydrationGate {
@@ -162,6 +164,7 @@ function createProjectionContext(
162164
manifestIds: new WeakMap<object, string>(),
163165
nextManifestId: 0,
164166
largeValueCount: 0,
167+
omittedContentFields: 0,
165168
sourceLargeValueBytes: 0,
166169
storedLargeValueBytes: 0,
167170
storedLargeValueCount: 0,
@@ -808,6 +811,7 @@ async function sanitizeContentField(
808811
logger.warn('Omitting trace content that could not be sanitized', {
809812
failure: describeProjectionFailure(error),
810813
})
814+
context.omittedContentFields += 1
811815
return OMIT
812816
}
813817
}
@@ -927,6 +931,7 @@ async function sanitizeTraceSpan(
927931
}
928932

929933
let projected = omit(span, ['displayResolvedSecretTraceProvenance']) as TraceSpan
934+
const omittedBefore = context.omittedContentFields
930935

931936
for (const key of ['input', 'output', 'thinking', 'errorMessage'] as const) {
932937
const value = span[key]
@@ -961,6 +966,8 @@ async function sanitizeTraceSpan(
961966
projected.providerTiming = { ...span.providerTiming, segments }
962967
}
963968

969+
if (context.omittedContentFields > omittedBefore) projected.contentWithheld = true
970+
964971
assertContentIsSafe(projected, depth)
965972

966973
if (span.children !== undefined) {
@@ -1074,6 +1081,7 @@ function structuralOnlySpan(
10741081

10751082
return {
10761083
...structural,
1084+
contentWithheld: true,
10771085
...(modelToolCalls
10781086
? {
10791087
modelToolCalls: projectBoundedArray(modelToolCalls, (call) =>

‎apps/sim/lib/logs/types.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,14 @@ export interface TraceSpan {
308308
* one read, not the run.
309309
*/
310310
childTraceAccess?: 'granted' | 'disabled' | 'missing' | 'truncated'
311+
/**
312+
* Set by secret projection when it omitted content it could not prove safe from
313+
* this span (`input`, `output`, `thinking`, `errorMessage`, or its tool-call and
314+
* provider-segment content), or reduced the span to structure only. Status, timing,
315+
* and usage remain. Without it a withheld field is
316+
* indistinguishable from one the block never produced.
317+
*/
318+
contentWithheld?: true
311319
model?: string
312320
cost?: {
313321
input?: number

‎apps/sim/lib/mothership/agent-cli/engines.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,68 @@ describe('logs query', () => {
101101
})
102102
})
103103

104+
const WITHHELD_RUNS = {
105+
[RUNS_PATH]: {
106+
data: [
107+
{ runId: 'run-1', status: 'completed' },
108+
{ runId: 'run-2', status: 'completed' },
109+
],
110+
},
111+
'/api/v2/logs/run-1': {
112+
data: {
113+
traceSpans: [{ name: 'Count rows', status: 'success', output: { result: { count: 6 } } }],
114+
},
115+
},
116+
// The log projection withheld this span's content; its structure survived.
117+
'/api/v2/logs/run-2': {
118+
data: {
119+
traceSpans: [{ name: 'Count rows', status: 'success', duration: 12, contentWithheld: true }],
120+
},
121+
},
122+
}
123+
124+
async function logsQueryWithheld(flags: Record<string, string>) {
125+
const result = await runEngine('logs query', ['wf-1'], runtimeWith(WITHHELD_RUNS), {
126+
block: 'Count rows',
127+
...flags,
128+
})
129+
expect(result.exitCode).toBe(0)
130+
return JSON.parse(result.stdout)
131+
}
132+
133+
describe('logs query on a span whose content was withheld', () => {
134+
it('reports withheld content instead of a missing path, and counts it', async () => {
135+
const report = await logsQueryWithheld({ field: 'result.count' })
136+
expect(report.rows[0]).toMatchObject({ runId: 'run-1', value: 6 })
137+
expect(report.rows[1]).toMatchObject({ runId: 'run-2', hits: 1, value: null })
138+
expect(report.rows[1].note).not.toBe('path not found on span')
139+
expect(report.rows[1].note).toMatch(/withheld/)
140+
expect(report.contentWithheld).toBe(1)
141+
})
142+
143+
it('still answers structural paths on a withheld span', async () => {
144+
const report = await logsQueryWithheld({ field: 'duration' })
145+
expect(report.rows[1]).toMatchObject({ runId: 'run-2', value: 12 })
146+
expect(report.rows[1].note).toBeUndefined()
147+
expect(report.contentWithheld).toBeUndefined()
148+
})
149+
150+
it('keeps a withheld run under --where instead of counting it as filtered out', async () => {
151+
const report = await logsQueryWithheld({ where: 'result.count=6' })
152+
expect(report.filteredOut).toBe(0)
153+
expect(report.rows.map((row: { runId: string }) => row.runId)).toEqual(['run-1', 'run-2'])
154+
expect(report.rows[1].note).toMatch(/withheld/)
155+
expect(report.contentWithheld).toBe(1)
156+
})
157+
158+
it('still resolves a readable --field when --where hits withheld content', async () => {
159+
const report = await logsQueryWithheld({ field: 'duration', where: 'result.count=6' })
160+
expect(report.rows[1]).toMatchObject({ runId: 'run-2', value: 12 })
161+
expect(report.rows[1].note).toMatch(/--where/)
162+
expect(report.contentWithheld).toBe(1)
163+
})
164+
})
165+
104166
const DEPS_STATE = {
105167
blocks: {
106168
'trigger-1': { type: 'starter', name: 'Start' },

‎apps/sim/lib/mothership/agent-cli/engines/query.ts‎

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ const MAX_LIMIT = 50
2323
const TRACE_FETCH_CONCURRENCY = 5
2424
const VALUE_MAX_CHARS = 600
2525
const PATH_NOT_FOUND_NOTE = 'path not found on span'
26+
/**
27+
* The log projection withheld this span's content rather than return what it could not
28+
* prove safe to display, so an unresolved content path says nothing about the run.
29+
*/
30+
const CONTENT_WITHHELD_NOTE = 'span content withheld by the log projection; value unknown'
31+
const WHERE_WITHHELD_NOTE =
32+
'span content withheld by the log projection; --where could not be checked, run kept'
2633

2734
/**
2835
* Keys that live on the span itself. A path whose head is anything else is read
@@ -56,6 +63,7 @@ interface QueryTraceSpan {
5663
duration?: number
5764
input?: Record<string, unknown>
5865
output?: Record<string, unknown>
66+
contentWithheld?: true
5967
children?: QueryTraceSpan[]
6068
}
6169

@@ -158,6 +166,7 @@ export const logsQueryCommand: AgentCliEngine = {
158166

159167
let filteredOut = 0
160168
let missingTrace = 0
169+
let contentWithheld = 0
161170
let fieldResolvedUnder: 'output' | undefined
162171
let whereResolvedUnder: 'output' | undefined
163172
const rows = await mapWithConcurrency(
@@ -193,23 +202,34 @@ export const logsQueryCommand: AgentCliEngine = {
193202
}
194203
return { ...base, hits: 0, value: null }
195204
}
205+
const matchedRow = {
206+
...base,
207+
hits: matches.length,
208+
blockStatus: last.status ?? 'success',
209+
}
210+
let whereWithheld = false
196211
if (wherePath) {
197212
const matched = resolveSpanPathWithFallback(last, wherePath)
198213
if (matched.resolvedUnder) whereResolvedUnder = matched.resolvedUnder
199-
if (String(matched.value) !== whereValue) {
214+
// Filtering on a withheld value would silently drop a run that may well match.
215+
whereWithheld = matched.value === undefined && last.contentWithheld === true
216+
if (!whereWithheld && String(matched.value) !== whereValue) {
200217
filteredOut++
201218
return undefined
202219
}
203220
}
204221
const resolved = resolveSpanPathWithFallback(last, field)
205222
if (resolved.resolvedUnder) fieldResolvedUnder = resolved.resolvedUnder
206-
return {
207-
...base,
208-
hits: matches.length,
209-
blockStatus: last.status ?? 'success',
210-
value: clipValue(resolved.value),
211-
...(resolved.value === undefined ? { note: PATH_NOT_FOUND_NOTE } : {}),
212-
}
223+
const fieldWithheld = resolved.value === undefined && last.contentWithheld === true
224+
if (whereWithheld || fieldWithheld) contentWithheld++
225+
const note = fieldWithheld
226+
? CONTENT_WITHHELD_NOTE
227+
: resolved.value === undefined
228+
? PATH_NOT_FOUND_NOTE
229+
: whereWithheld
230+
? WHERE_WITHHELD_NOTE
231+
: undefined
232+
return { ...matchedRow, value: clipValue(resolved.value), ...(note ? { note } : {}) }
213233
}
214234
)
215235

@@ -230,6 +250,7 @@ export const logsQueryCommand: AgentCliEngine = {
230250
}
231251
: {}),
232252
...(missingTrace ? { missingTrace } : {}),
253+
...(contentWithheld ? { contentWithheld } : {}),
233254
rows: kept,
234255
},
235256
null,

‎packages/sim-cli/src/generated/v2-api.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6244,6 +6244,7 @@ type GetLogResponseRef1 = {
62446244
toolCost?: number
62456245
}
62466246
relativeStartMs?: number
6247+
contentWithheld?: true
62476248
toolCalls?: Array<{
62486249
id?: string
62496250
name?: string
@@ -9037,6 +9038,7 @@ type ListLogsResponseRef2 = {
90379038
toolCost?: number
90389039
}
90399040
relativeStartMs?: number
9041+
contentWithheld?: true
90409042
toolCalls?: Array<{
90419043
id?: string
90429044
name?: string

0 commit comments

Comments
 (0)