Skip to content

Commit b043ef0

Browse files
committed
fix(logs): mark withheld trace span content so logs query stops reporting it as missing
When secret projection omits a span's content field or falls back to structural-only spans, it now sets `contentWithheld: true` on the span. The v2 log contract documents the marker, and `logs query` reports a withheld note (and a `contentWithheld` count) instead of "path not found on span", and keeps such runs under --where rather than silently filtering them out.
1 parent 93c71ad commit b043ef0

8 files changed

Lines changed: 156 additions & 7 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) 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) 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: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,57 @@ 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+
505556
it('fails closed when a later log transform reintroduces an active literal', async () => {
506557
const source = [createSpan({ output: { apiKey: '[REDACTED]' } })]
507558

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -965,7 +965,7 @@ async function sanitizeTraceSpan(
965965
if (value === undefined) continue
966966
const sanitized = await sanitizeContentField(value, context)
967967
if (sanitized === OMIT) {
968-
projected = omit(projected, [key]) as TraceSpan
968+
projected = { ...(omit(projected, [key]) as TraceSpan), contentWithheld: true }
969969
} else {
970970
projected[key] = sanitized as never
971971
}
@@ -1106,6 +1106,7 @@ function structuralOnlySpan(
11061106

11071107
return {
11081108
...structural,
1109+
contentWithheld: true,
11091110
...(modelToolCalls
11101111
? {
11111112
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 one of this span's own content fields
313+
* (`input`, `output`, `thinking`, `errorMessage`) it could not prove safe, or reduced
314+
* the span to structure only (which also strips tool-call and provider-segment
315+
* content). Status, timing, 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: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,61 @@ 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+
104159
const DEPS_STATE = {
105160
blocks: {
106161
'trigger-1': { type: 'starter', name: 'Start' },

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

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ 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'
2631

2732
/**
2833
* Keys that live on the span itself. A path whose head is anything else is read
@@ -56,6 +61,7 @@ interface QueryTraceSpan {
5661
duration?: number
5762
input?: Record<string, unknown>
5863
output?: Record<string, unknown>
64+
contentWithheld?: true
5965
children?: QueryTraceSpan[]
6066
}
6167

@@ -158,6 +164,7 @@ export const logsQueryCommand: AgentCliEngine = {
158164

159165
let filteredOut = 0
160166
let missingTrace = 0
167+
let contentWithheld = 0
161168
let fieldResolvedUnder: 'output' | undefined
162169
let whereResolvedUnder: 'output' | undefined
163170
const rows = await mapWithConcurrency(
@@ -193,23 +200,35 @@ export const logsQueryCommand: AgentCliEngine = {
193200
}
194201
return { ...base, hits: 0, value: null }
195202
}
203+
const matchedRow = {
204+
...base,
205+
hits: matches.length,
206+
blockStatus: last.status ?? 'success',
207+
}
196208
if (wherePath) {
197209
const matched = resolveSpanPathWithFallback(last, wherePath)
198210
if (matched.resolvedUnder) whereResolvedUnder = matched.resolvedUnder
211+
// Filtering on a withheld value would silently drop a run that may well match.
212+
if (matched.value === undefined && last.contentWithheld) {
213+
contentWithheld++
214+
return { ...matchedRow, value: null, note: CONTENT_WITHHELD_NOTE }
215+
}
199216
if (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 } : {}),
223+
if (resolved.value === undefined) {
224+
if (last.contentWithheld) contentWithheld++
225+
return {
226+
...matchedRow,
227+
value: null,
228+
note: last.contentWithheld ? CONTENT_WITHHELD_NOTE : PATH_NOT_FOUND_NOTE,
229+
}
212230
}
231+
return { ...matchedRow, value: clipValue(resolved.value) }
213232
}
214233
)
215234

@@ -230,6 +249,7 @@ export const logsQueryCommand: AgentCliEngine = {
230249
}
231250
: {}),
232251
...(missingTrace ? { missingTrace } : {}),
252+
...(contentWithheld ? { contentWithheld } : {}),
233253
rows: kept,
234254
},
235255
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)