diff --git a/apps/sim/lib/logs/execution/trace-secret-projection.test.ts b/apps/sim/lib/logs/execution/trace-secret-projection.test.ts index 84b214b1e55..b8c05a36a13 100644 --- a/apps/sim/lib/logs/execution/trace-secret-projection.test.ts +++ b/apps/sim/lib/logs/execution/trace-secret-projection.test.ts @@ -1019,6 +1019,13 @@ describe('projectTraceSpansForSecrets', () => { } expect(warnMock).toHaveBeenCalledWith('Omitting trace content that could not be sanitized', { + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + phase: 'write', + spanId: 'span-1', + spanType: 'function', + field: 'output', failure: { name: 'SyntaxError' }, }) }) @@ -1048,3 +1055,145 @@ describe('projectTraceSpansForSecrets', () => { expect(cursor?.children).toEqual([]) }) }) + +describe('projectTraceSpansForSecrets whole-trace content budget', () => { + const SECRET_REGISTRY = createRegistry([ + { plaintext: 'top-secret', replacement: '{{API_SECRET}}' }, + ]) + + /** A span whose output alone fits the content budget but two-and-a-half of which do not. */ + function heavySpan(id: string, marker: string, overrides: Partial = {}): TraceSpan { + return createSpan({ + id, + output: { marker, items: Array.from({ length: 40_000 }, (_, index) => `row-${index}`) }, + ...overrides, + }) + } + + it('keeps the spans checked before the budget ran out and withholds the rest', async () => { + const projected = await projectTraceSpansForSecrets( + [ + heavySpan('span-a', 'first top-secret'), + heavySpan('span-b', 'second'), + heavySpan('span-c', 'third top-secret'), + createSpan({ id: 'span-d', output: { marker: 'fourth top-secret' } }), + ], + { registry: SECRET_REGISTRY, store: STORE, allowLargeValueWrites: false } + ) + + expect(projected.map((span) => span.id)).toEqual(['span-a', 'span-b', 'span-c', 'span-d']) + expect(projected[0].output?.marker).toBe('first {{API_SECRET}}') + expect(projected[0]).not.toHaveProperty('contentWithheld') + expect(projected[1].output?.marker).toBe('second') + for (const withheld of projected.slice(2)) { + expect(withheld).not.toHaveProperty('output') + expect(withheld.contentWithheld).toBe(true) + } + expect(JSON.stringify(projected)).not.toContain('top-secret') + expect(warnMock).toHaveBeenCalledWith( + 'Trace content exceeded the projection budget; withholding the remaining spans', + expect.objectContaining({ + executionId: 'execution-1', + phase: 'read', + retainedSpanCount: 2, + withheldSpanCount: 2, + }) + ) + }) + + it('still withholds every span when a later span fails for any reason but the budget', async () => { + const projected = await projectTraceSpansForSecrets( + [ + createSpan({ id: 'span-a', output: { marker: 'first' } }), + createSpan({ + id: 'span-b', + toolCalls: Array.from({ length: 100_001 }, (_, index) => ({ + name: `tool-${index}`, + duration: 0, + startTime: '2026-07-01T00:00:00.000Z', + endTime: '2026-07-01T00:00:00.000Z', + status: 'success', + })), + }), + ], + { registry: SECRET_REGISTRY, store: STORE, allowLargeValueWrites: false } + ) + + expect(projected[0]).not.toHaveProperty('output') + expect(projected[0].contentWithheld).toBe(true) + expect(warnMock).toHaveBeenCalledWith( + 'Trace secret projection failed; retaining structural spans only', + expect.objectContaining({ executionId: 'execution-1', phase: 'read' }) + ) + }) + + it('keeps the retained spans and the withheld fallback within one structure budget', async () => { + const emptyChildren = (prefix: string) => + Array.from({ length: 60_000 }, (_, index) => createSpan({ id: `${prefix}-${index}` })) + const projected = await projectTraceSpansForSecrets( + [ + heavySpan('span-a', 'first', { children: emptyChildren('a') }), + createSpan({ + id: 'span-b', + output: { items: Array.from({ length: 70_000 }, (_, index) => `row-${index}`) }, + children: emptyChildren('b'), + }), + ], + { registry: SECRET_REGISTRY, store: STORE, allowLargeValueWrites: false } + ) + const countSpans = (spans: TraceSpan[]): number => + spans.reduce((total, span) => total + 1 + countSpans(span.children ?? []), 0) + + expect(projected[0].output?.marker).toBe('first') + expect(projected[1]).not.toHaveProperty('output') + expect(countSpans(projected)).toBeLessThanOrEqual(100_000) + }) + + it('keeps the structure a whole-trace fallback would for the span that crossed the budget', async () => { + const toolCall = (index: number) => ({ + name: `tool-${index}`, + duration: 0, + startTime: '2026-07-01T00:00:00.000Z', + endTime: '2026-07-01T00:00:00.000Z', + status: 'success' as const, + }) + const projected = await projectTraceSpansForSecrets( + [ + heavySpan('span-a', 'first', { + children: Array.from({ length: 30_000 }, (_, index) => createSpan({ id: `a-${index}` })), + }), + createSpan({ + id: 'span-b', + output: { items: Array.from({ length: 70_000 }, (_, index) => `row-${index}`) }, + toolCalls: Array.from({ length: 50_000 }, (_, index) => toolCall(index)), + }), + ], + { registry: SECRET_REGISTRY, store: STORE, allowLargeValueWrites: false } + ) + + expect(projected[0].output?.marker).toBe('first') + expect(projected[1]).not.toHaveProperty('output') + expect(projected[1].toolCalls).toHaveLength(50_000) + }) + + it('withholds the whole top-level span whose descendant crossed the budget', async () => { + const projected = await projectTraceSpansForSecrets( + [ + heavySpan('span-a', 'first'), + createSpan({ + id: 'span-b', + output: { marker: 'parent top-secret' }, + children: [heavySpan('child-1', 'child one'), heavySpan('child-2', 'child two')], + }), + ], + { registry: SECRET_REGISTRY, store: STORE, allowLargeValueWrites: false } + ) + + expect(projected[0].output?.marker).toBe('first') + expect(projected[1]).not.toHaveProperty('output') + expect(projected[1].contentWithheld).toBe(true) + expect(projected[1].children?.map((child) => child.id)).toEqual(['child-1', 'child-2']) + expect(projected[1].children?.every((child) => !Object.hasOwn(child, 'output'))).toBe(true) + expect(JSON.stringify(projected)).not.toContain('top-secret') + }) +}) diff --git a/apps/sim/lib/logs/execution/trace-secret-projection.ts b/apps/sim/lib/logs/execution/trace-secret-projection.ts index 9b3a1110476..b5e5aae17d6 100644 --- a/apps/sim/lib/logs/execution/trace-secret-projection.ts +++ b/apps/sim/lib/logs/execution/trace-secret-projection.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { isPlainRecord, omit } from '@sim/utils/object' +import { filterUndefined, isPlainRecord, omit } from '@sim/utils/object' import { yieldWhenSliceExpires } from '@/lib/core/utils/event-loop' import { isLargeArrayManifest, @@ -84,6 +84,7 @@ interface ProjectionContext { storedLargeValueCount: number /** Content fields omitted so far; a span compares it across its own fields to set `contentWithheld`. */ omittedContentFields: number + logDetails: ProjectionLogDetails } interface OversizedHydrationGate { @@ -132,6 +133,43 @@ class TraceSecretProjectionError extends Error { } } +/** + * The content walk ran out of its node budget. Unlike every other projection failure, this says + * nothing about the content already checked: it only bounds how much more is walked. + */ +class TraceContentBudgetError extends TraceSecretProjectionError {} + +/** + * Identifies the run behind a projection warning. `phase` says whether the run was being written + * to the log or a stored log was being read for display. + */ +interface ProjectionLogDetails { + executionId?: string + workflowId?: string + workspaceId?: string + phase: 'read' | 'write' +} + +function projectionLogDetails( + store: LargeValueStoreContext, + phase: ProjectionLogDetails['phase'] +): ProjectionLogDetails { + return { + ...filterUndefined({ + executionId: store.executionId, + workflowId: store.workflowId, + workspaceId: store.workspaceId, + }), + phase, + } +} + +/** The span whose content field a per-field warning names. */ +interface ContentFieldOwner { + id: string + type: string +} + /** * Diagnostic payload for a projection fallback. * @@ -168,13 +206,14 @@ function createProjectionContext( sourceLargeValueBytes: 0, storedLargeValueBytes: 0, storedLargeValueCount: 0, + logDetails: projectionLogDetails(store, allowLargeValueWrites ? 'write' : 'read'), } } function visitNode(state: TraversalState, depth: number): void { state.nodes += 1 if (state.nodes > MAX_CONTENT_NODES) { - throw new TraceSecretProjectionError('Trace content node limit exceeded') + throw new TraceContentBudgetError('Trace content node limit exceeded') } if (depth > MAX_CONTENT_DEPTH) { throw new TraceSecretProjectionError('Trace content depth limit exceeded') @@ -183,7 +222,7 @@ function visitNode(state: TraversalState, depth: number): void { function assertArrayFitsTraversal(value: readonly unknown[], state: TraversalState): void { if (value.length > MAX_CONTENT_NODES - state.nodes) { - throw new TraceSecretProjectionError('Trace content array exceeds the traversal limit') + throw new TraceContentBudgetError('Trace content array exceeds the traversal limit') } } @@ -802,13 +841,19 @@ async function replaceLargeValues( async function sanitizeContentField( value: unknown, - context: ProjectionContext + context: ProjectionContext, + owner: ContentFieldOwner, + field: string ): Promise { await yieldWhenSliceExpires() try { return await sanitizeMaterializedValue(value, context) } catch (error) { logger.warn('Omitting trace content that could not be sanitized', { + ...context.logDetails, + spanId: owner.id, + spanType: owner.type, + field, failure: describeProjectionFailure(error), }) context.omittedContentFields += 1 @@ -829,6 +874,8 @@ function assertTraceStructureArrayFits( async function sanitizeIterationToolCalls( calls: IterationToolCall[], context: ProjectionContext, + owner: ContentFieldOwner, + field: string, state: BoundedTraceStructureState, depth: number ): Promise { @@ -839,7 +886,7 @@ async function sanitizeIterationToolCalls( if (!takeTraceStructureNode(state, depth)) { throw new TraceSecretProjectionError('Trace tool-call structure limit exceeded') } - const args = await sanitizeContentField(call.arguments, context) + const args = await sanitizeContentField(call.arguments, context, owner, `${field}.arguments`) sanitized.push( args === OMIT ? (omit(call, ['arguments']) as IterationToolCall) @@ -852,6 +899,7 @@ async function sanitizeIterationToolCalls( async function sanitizeLegacyToolCalls( calls: ToolCall[], context: ProjectionContext, + owner: ContentFieldOwner, state: BoundedTraceStructureState, depth: number ): Promise { @@ -864,17 +912,17 @@ async function sanitizeLegacyToolCalls( } let projected: ToolCall = { ...call } if (call.input !== undefined) { - const input = await sanitizeContentField(call.input, context) + const input = await sanitizeContentField(call.input, context, owner, 'toolCalls.input') if (input === OMIT) projected = omit(projected, ['input']) as ToolCall else projected.input = input as Record } if (call.output !== undefined) { - const output = await sanitizeContentField(call.output, context) + const output = await sanitizeContentField(call.output, context, owner, 'toolCalls.output') if (output === OMIT) projected = omit(projected, ['output']) as ToolCall else projected.output = output as Record } if (call.error !== undefined) { - const error = await sanitizeContentField(call.error, context) + const error = await sanitizeContentField(call.error, context, owner, 'toolCalls.error') if (error === OMIT) projected = omit(projected, ['error']) as ToolCall else projected.error = error as string } @@ -886,6 +934,7 @@ async function sanitizeLegacyToolCalls( async function sanitizeProviderSegment( segment: ProviderTimingSegment, context: ProjectionContext, + owner: ContentFieldOwner, state: BoundedTraceStructureState, depth: number ): Promise { @@ -898,7 +947,12 @@ async function sanitizeProviderSegment( for (const key of ['assistantContent', 'thinkingContent', 'errorMessage'] as const) { const value = segment[key] if (value === undefined) continue - const sanitized = await sanitizeContentField(value, context) + const sanitized = await sanitizeContentField( + value, + context, + owner, + `providerTiming.segments.${key}` + ) if (sanitized === OMIT) { projected = omit(projected, [key]) as ProviderTimingSegment } else { @@ -910,6 +964,8 @@ async function sanitizeProviderSegment( projected.toolCalls = await sanitizeIterationToolCalls( segment.toolCalls, context, + owner, + 'providerTiming.segments.toolCalls', state, depth + 1 ) @@ -936,7 +992,7 @@ async function sanitizeTraceSpan( for (const key of ['input', 'output', 'thinking', 'errorMessage'] as const) { const value = span[key] if (value === undefined) continue - const sanitized = await sanitizeContentField(value, context) + const sanitized = await sanitizeContentField(value, context, span, key) if (sanitized === OMIT) { projected = omit(projected, [key]) as TraceSpan } else { @@ -948,20 +1004,28 @@ async function sanitizeTraceSpan( projected.modelToolCalls = await sanitizeIterationToolCalls( span.modelToolCalls, context, + span, + 'modelToolCalls', state, depth + 1 ) } if (span.toolCalls !== undefined) { - projected.toolCalls = await sanitizeLegacyToolCalls(span.toolCalls, context, state, depth + 1) + projected.toolCalls = await sanitizeLegacyToolCalls( + span.toolCalls, + context, + span, + state, + depth + 1 + ) } if (span.providerTiming !== undefined) { assertTraceStructureArrayFits(span.providerTiming.segments, state) const segments: ProviderTimingSegment[] = [] for (const segment of span.providerTiming.segments) { - segments.push(await sanitizeProviderSegment(segment, context, state, depth + 1)) + segments.push(await sanitizeProviderSegment(segment, context, span, state, depth + 1)) } projected.providerTiming = { ...span.providerTiming, segments } } @@ -1204,9 +1268,9 @@ function projectBoundedTraceSpans( span: TraceSpan, state: BoundedTraceStructureState, depth: number - ) => TraceSpan | undefined + ) => TraceSpan | undefined, + state: BoundedTraceStructureState = { nodes: 0, truncated: false } ): TraceSpan[] { - const state: BoundedTraceStructureState = { nodes: 0, truncated: false } const projected = projectBoundedArray(traceSpans, (span) => project(span, state, 0)) if (state.truncated) { logger.warn('Trace structure exceeded projection limits; truncating projected structure') @@ -1214,9 +1278,13 @@ function projectBoundedTraceSpans( return projected } -function structuralOnlyTraceSpans(traceSpans: TraceSpan[]): TraceSpan[] { +/** Pass `state` to continue a structure budget that earlier spans of the same trace already spent. */ +function structuralOnlyTraceSpans( + traceSpans: TraceSpan[], + state?: BoundedTraceStructureState +): TraceSpan[] { try { - return projectBoundedTraceSpans(traceSpans, structuralOnlySpan) + return projectBoundedTraceSpans(traceSpans, structuralOnlySpan, state) } catch (error) { logger.warn('Trace structure could not be safely traversed; omitting projected spans', { failure: describeProjectionFailure(error), @@ -1582,6 +1650,7 @@ export async function enforceTraceSpanSecretInvariant( return traceSpans } catch (error) { logger.warn('Trace secret invariant failed; retaining structural spans only', { + ...projectionLogDetails(options.store, 'write'), failure: describeProjectionFailure(error), }) return structuralOnlyTraceSpans(traceSpans) @@ -1600,28 +1669,56 @@ export async function projectTraceSpansForSecrets( return structuralOnlyTraceSpans(traceSpans) } + const allowLargeValueWrites = options.allowLargeValueWrites !== false + const logDetails = projectionLogDetails(options.store, allowLargeValueWrites ? 'write' : 'read') try { const matcher = createResolvedSecretMatcher(options.registry.getActiveMatches(), { preserveNamedProvenanceLabels: true, }) if (!matcher) return cloneTraceSpansForProjection(traceSpans) - const context = createProjectionContext( - matcher, - options.store, - options.allowLargeValueWrites !== false - ) + const context = createProjectionContext(matcher, options.store, allowLargeValueWrites) const projected: TraceSpan[] = [] const state: BoundedTraceStructureState = { nodes: 0, truncated: false } assertTraceStructureArrayFits(traceSpans, state) const assertContentIsSafe = createSpanContentAssertion(traceSpans, context) - for (const span of traceSpans) { - projected.push(await sanitizeTraceSpan(span, context, 0, state, assertContentIsSafe)) + for (let index = 0; index < traceSpans.length; index += 1) { + const retainedStructureNodes = state.nodes + try { + projected.push( + await sanitizeTraceSpan(traceSpans[index], context, 0, state, assertContentIsSafe) + ) + } catch (error) { + if (!(error instanceof TraceContentBudgetError)) throw error + /** + * Every span already returned was sanitized and then passed the whole-trace check, + * descendants included, so only the span that crossed the budget and those after it + * lose their content. The fallback resumes the structure budget where the retained + * spans left it, so the trace keeps exactly the structure a whole-trace fallback would. + */ + logger.warn( + 'Trace content exceeded the projection budget; withholding the remaining spans', + { + ...logDetails, + retainedSpanCount: index, + withheldSpanCount: traceSpans.length - index, + failure: describeProjectionFailure(error), + } + ) + return [ + ...projected, + ...structuralOnlyTraceSpans(traceSpans.slice(index), { + nodes: retainedStructureNodes, + truncated: false, + }), + ] + } } return projected } catch (error) { logger.warn('Trace secret projection failed; retaining structural spans only', { + ...logDetails, failure: describeProjectionFailure(error), }) return structuralOnlyTraceSpans(traceSpans) diff --git a/apps/sim/lib/logs/execution/trace-store.test.ts b/apps/sim/lib/logs/execution/trace-store.test.ts index 3e0a53c2d48..89b7c24bee6 100644 --- a/apps/sim/lib/logs/execution/trace-store.test.ts +++ b/apps/sim/lib/logs/execution/trace-store.test.ts @@ -509,6 +509,104 @@ describe('projectExecutionDataForDisplay', () => { }) }) +describe('projectExecutionDataForDisplay envelope isolation', () => { + const RUN_PROVENANCE = { + version: 1 as const, + complete: true, + entries: [{ name: 'OPENAI_API_KEY', encryptedValue: 'ciphertext' }], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + } + + function functionSpan(id: string, output: Record): TraceSpan { + return { + id, + name: 'Function 1', + type: 'function', + duration: 1, + startTime: '2026-07-31T00:00:00.000Z', + endTime: '2026-07-31T00:00:00.001Z', + output, + } + } + + it('withholds only the envelope value that holds a stored large value', async () => { + const displayData = await projectExecutionDataForDisplay( + { + error: 'Request failed with 12345678', + message: 'Run failed', + blockExecutions: [ + { + blockId: 'function-1', + output: { + __simLargeValueRef: true, + version: 1, + id: 'lv_bbbbbbbbbbbb', + kind: 'string', + size: 32, + }, + }, + ], + traceSpans: [functionSpan('span-1', { result: 12345678 })], + executionState: { resolvedSecretTraceProvenance: RUN_PROVENANCE }, + }, + CONTEXT + ) + + expect(displayData.error).toBe('Request failed with {{OPENAI_API_KEY}}') + expect(displayData.message).toBe('Run failed') + expect(displayData).not.toHaveProperty('blockExecutions') + expect(displayData.traceSpans).toEqual([ + expect.objectContaining({ id: 'span-1', output: { result: '{{OPENAI_API_KEY}}' } }), + ]) + expect(JSON.stringify(displayData)).not.toContain('12345678') + }) + + it('never mistakes a stored span for an envelope value', async () => { + const displayData = await projectExecutionDataForDisplay( + { + error: 'Run failed', + traceSpans: [ + functionSpan('secret-safe-log-display-projection-error', { value: 'from a span' }), + ], + executionState: { resolvedSecretTraceProvenance: RUN_PROVENANCE }, + }, + CONTEXT + ) + + expect(displayData.error).toBe('Run failed') + expect(displayData.traceSpans).toEqual([ + expect.objectContaining({ + id: 'secret-safe-log-display-projection-error', + output: { value: 'from a span' }, + }), + ]) + }) + + it('spends the shared content budget on the trace before the block dumps', async () => { + const displayData = await projectExecutionDataForDisplay( + { + error: 'Request failed with 12345678', + blockExecutions: Array.from({ length: 70_000 }, (_, index) => `row-${index}`), + traceSpans: [ + functionSpan('span-1', { + result: 12345678, + rows: Array.from({ length: 40_000 }, (_, index) => `row-${index}`), + }), + ], + executionState: { resolvedSecretTraceProvenance: RUN_PROVENANCE }, + }, + CONTEXT + ) + + expect(displayData.error).toBe('Request failed with {{OPENAI_API_KEY}}') + expect(displayData).not.toHaveProperty('blockExecutions') + const [span] = displayData.traceSpans as TraceSpan[] + expect(span.output?.result).toBe('{{OPENAI_API_KEY}}') + expect(span).not.toHaveProperty('contentWithheld') + expect(JSON.stringify(displayData)).not.toContain('12345678') + }) +}) + describe('projectExecutionDataForDisplay provenance handling', () => { const PROVENANCE = { version: 1, complete: true, entries: [] } as const diff --git a/apps/sim/lib/logs/execution/trace-store.ts b/apps/sim/lib/logs/execution/trace-store.ts index 48c86eb92a2..be84ce98b99 100644 --- a/apps/sim/lib/logs/execution/trace-store.ts +++ b/apps/sim/lib/logs/execution/trace-store.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { describeError, toError } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' import { isRecordLike, omit } from '@sim/utils/object' import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits' import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref' @@ -374,16 +375,25 @@ export async function materializeExecutionData( } } -const LOG_DISPLAY_CONTENT_KEYS = [ - 'finalOutput', - 'workflowInput', - 'blockInput', - 'blockExecutions', +/** + * Display envelope values project one per span, so an oversized or ref-backed value withholds + * only itself. They share the trace spans' content budget, so the bulky block dumps go after the + * spans: when the budget runs out, those lose their content before the run's error or its trace. + */ +const LEADING_LOG_DISPLAY_CONTENT_KEYS = [ 'error', - 'errorDetails', - 'completionFailure', 'message', + 'completionFailure', + 'errorDetails', + 'finalOutput', + 'workflowInput', ] as const +const TRAILING_LOG_DISPLAY_CONTENT_KEYS = ['blockInput', 'blockExecutions'] as const +const LOG_DISPLAY_CONTENT_KEYS = [ + ...LEADING_LOG_DISPLAY_CONTENT_KEYS, + ...TRAILING_LOG_DISPLAY_CONTENT_KEYS, +] as const +type LogDisplayContentKey = (typeof LOG_DISPLAY_CONTENT_KEYS)[number] const LOG_DISPLAY_PROJECTION_SPAN_ID = 'secret-safe-log-display-projection' const EXACT_LOG_VALUE_PROVENANCE_KEYS = { @@ -733,37 +743,46 @@ export async function projectExecutionDataForDisplay( } reportStoredDisplayProvenanceFaults('traceStore.displayProjection', context, provenanceFaults) - const envelope: Record = {} - for (const key of LOG_DISPLAY_CONTENT_KEYS) { - const exactProvenanceKey = - EXACT_LOG_VALUE_PROVENANCE_KEYS[key as keyof typeof EXACT_LOG_VALUE_PROVENANCE_KEYS] - if ( - Object.hasOwn(executionData, key) && - (!exactProvenanceKey || !executionState || !Object.hasOwn(executionState, exactProvenanceKey)) - ) { - envelope[key] = executionData[key] - } - } - const now = new Date().toISOString() - const syntheticSpan: TraceSpan = { - id: LOG_DISPLAY_PROJECTION_SPAN_ID, - name: 'Log Display Projection', - type: 'display', - duration: 0, - startTime: now, - endTime: now, - output: envelope, - } + /** Unguessable per call, so no stored span id can be mistaken for an envelope value. */ + const envelopeSpanIdPrefix = `${LOG_DISPLAY_PROJECTION_SPAN_ID}-${generateShortId()}` + const envelopeKeysBySpanId = new Map() + const envelopeSpans = (keys: readonly LogDisplayContentKey[]): TraceSpan[] => + keys.flatMap((key) => { + const exactProvenanceKey = + EXACT_LOG_VALUE_PROVENANCE_KEYS[key as keyof typeof EXACT_LOG_VALUE_PROVENANCE_KEYS] + if ( + !Object.hasOwn(executionData, key) || + (exactProvenanceKey && executionState && Object.hasOwn(executionState, exactProvenanceKey)) + ) { + return [] + } + const id = `${envelopeSpanIdPrefix}-${key}` + envelopeKeysBySpanId.set(id, key) + return [ + { + id, + name: 'Log Display Projection', + type: 'display', + duration: 0, + startTime: now, + endTime: now, + output: { value: executionData[key] }, + }, + ] + }) const sourceTraceSpans = Array.isArray(executionData.traceSpans) ? (executionData.traceSpans as TraceSpan[]) : [] const spansToProject = retainStoredTraceSpans ? [] : sourceTraceSpans - const projectedSpans = await projectTraceSpansForSecrets([syntheticSpan, ...spansToProject], { - registry, - allowLargeValueWrites: false, - store: projectionStore, - }) + const projectedSpans = await projectTraceSpansForSecrets( + [ + ...envelopeSpans(LEADING_LOG_DISPLAY_CONTENT_KEYS), + ...spansToProject, + ...envelopeSpans(TRAILING_LOG_DISPLAY_CONTENT_KEYS), + ], + { registry, allowLargeValueWrites: false, store: projectionStore } + ) const displayData = omit(executionData, [ ...LOG_DISPLAY_CONTENT_KEYS, @@ -771,12 +790,10 @@ export async function projectExecutionDataForDisplay( 'traceSpans', ]) as Record - const projectedEnvelope = projectedSpans.find( - (span) => span.id === LOG_DISPLAY_PROJECTION_SPAN_ID - )?.output - if (projectedEnvelope) { - for (const key of LOG_DISPLAY_CONTENT_KEYS) { - if (Object.hasOwn(projectedEnvelope, key)) displayData[key] = projectedEnvelope[key] + for (const span of projectedSpans) { + const key = envelopeKeysBySpanId.get(span.id) + if (key && span.output && Object.hasOwn(span.output, 'value')) { + displayData[key] = span.output.value } } for (const [key, value] of exactValueProjections) { @@ -786,7 +803,7 @@ export async function projectExecutionDataForDisplay( if (Array.isArray(executionData.traceSpans)) { displayData.traceSpans = retainStoredTraceSpans ? sourceTraceSpans - : projectedSpans.filter((span) => span.id !== LOG_DISPLAY_PROJECTION_SPAN_ID) + : projectedSpans.filter((span) => !envelopeKeysBySpanId.has(span.id)) } return displayData