11import { createLogger } from '@sim/logger'
22import { toError } from '@sim/utils/errors'
3- import { isPlainRecord , omit } from '@sim/utils/object'
3+ import { filterUndefined , isPlainRecord , omit } from '@sim/utils/object'
44import { yieldWhenSliceExpires } from '@/lib/core/utils/event-loop'
55import {
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
8990interface 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
174210function 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
184220function 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
803839async 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(
829871async 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(
852896async 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(
886931async 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