Skip to content

Commit 55814db

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
sanitize workflow output logs
1 parent 95d4fd1 commit 55814db

5 files changed

Lines changed: 348 additions & 29 deletions

File tree

apps/sim/lib/logs/execution/logging-session.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ vi.mock('@/lib/logs/execution/logging-factory', () => ({
7373
}))
7474

7575
import { calculateCostSummary } from '@/lib/logs/execution/logging-factory'
76+
import { createEnvironmentSecretSanitizer } from '@/executor/utils/environment-secret-sanitizer'
7677
import { LoggingSession } from './logging-session'
7778

7879
afterAll(resetDbChainMock)
@@ -268,6 +269,123 @@ describe('LoggingSession completion retries', () => {
268269
)
269270
})
270271

272+
it('sanitizes workflow output and final trace spans without mutating runtime values', async () => {
273+
const session = new LoggingSession('workflow-1', 'execution-safe', 'api', 'req-1')
274+
const secret = 'sk-demo / trace?token=7f3a91'
275+
const rawFinalOutput = {
276+
result: {
277+
resolvedAtRuntime: true,
278+
echoed: `prefix:${secret}:suffix`,
279+
encoded: encodeURIComponent(secret),
280+
ordinary: 'us-east-1',
281+
},
282+
}
283+
const rawTraceSpans = [
284+
{
285+
id: 'span-safe',
286+
name: 'Function',
287+
type: 'function',
288+
duration: 1,
289+
startTime: '2026-07-01T00:00:00.000Z',
290+
endTime: '2026-07-01T00:00:00.001Z',
291+
status: 'success',
292+
output: { echoed: secret },
293+
},
294+
]
295+
296+
session.setEnvironmentSecretSanitizer(
297+
createEnvironmentSecretSanitizer(
298+
{ code: 'return "{{OPENAI_API_KEY}}"' },
299+
{
300+
OPENAI_API_KEY: secret,
301+
UNREFERENCED_REGION: 'us-east-1',
302+
}
303+
)
304+
)
305+
completeWorkflowExecutionMock.mockResolvedValue({})
306+
307+
await session.safeComplete({
308+
finalOutput: rawFinalOutput,
309+
traceSpans: rawTraceSpans as any,
310+
})
311+
312+
expect(completeWorkflowExecutionMock).toHaveBeenCalledWith(
313+
expect.objectContaining({
314+
finalOutput: {
315+
result: {
316+
resolvedAtRuntime: true,
317+
echoed: 'prefix:{{OPENAI_API_KEY}}:suffix',
318+
encoded: '{{OPENAI_API_KEY}}',
319+
ordinary: 'us-east-1',
320+
},
321+
},
322+
traceSpans: [
323+
expect.objectContaining({
324+
output: { echoed: '{{OPENAI_API_KEY}}' },
325+
}),
326+
],
327+
})
328+
)
329+
expect(rawFinalOutput.result.echoed).toBe(`prefix:${secret}:suffix`)
330+
expect(rawTraceSpans[0].output.echoed).toBe(secret)
331+
expect(calculateCostSummary).toHaveBeenCalledWith(rawTraceSpans)
332+
})
333+
334+
it('sanitizes synthetic workflow errors and completion failure metadata', async () => {
335+
const session = new LoggingSession('workflow-1', 'execution-error-safe', 'api', 'req-1')
336+
const secret = 'sk-demo-error-7f3a91'
337+
338+
session.setEnvironmentSecretSanitizer(
339+
createEnvironmentSecretSanitizer(
340+
{ code: 'throw new Error("{{OPENAI_API_KEY}}")' },
341+
{ OPENAI_API_KEY: secret }
342+
)
343+
)
344+
completeWorkflowExecutionMock.mockResolvedValue({})
345+
346+
await session.safeCompleteWithError({
347+
error: { message: `Function failed with ${secret}` },
348+
})
349+
350+
expect(completeWorkflowExecutionMock).toHaveBeenCalledWith(
351+
expect.objectContaining({
352+
finalOutput: { error: 'Function failed with {{OPENAI_API_KEY}}' },
353+
traceSpans: [
354+
expect.objectContaining({
355+
output: { error: 'Function failed with {{OPENAI_API_KEY}}' },
356+
}),
357+
],
358+
completionFailure: 'Function failed with {{OPENAI_API_KEY}}',
359+
})
360+
)
361+
})
362+
363+
it('keeps workflow output sanitized when completion falls back to cost-only persistence', async () => {
364+
const session = new LoggingSession('workflow-1', 'execution-fallback-safe', 'api', 'req-1')
365+
const secret = 'sk-demo-fallback-7f3a91'
366+
367+
session.setEnvironmentSecretSanitizer(
368+
createEnvironmentSecretSanitizer(
369+
{ code: 'return "{{OPENAI_API_KEY}}"' },
370+
{ OPENAI_API_KEY: secret }
371+
)
372+
)
373+
completeWorkflowExecutionMock
374+
.mockRejectedValueOnce(new Error('primary persistence failed'))
375+
.mockResolvedValueOnce({})
376+
377+
await session.safeComplete({
378+
finalOutput: { echoed: secret },
379+
})
380+
381+
expect(completeWorkflowExecutionMock).toHaveBeenLastCalledWith(
382+
expect.objectContaining({
383+
finalOutput: { echoed: '{{OPENAI_API_KEY}}' },
384+
finalizationPath: 'fallback_completed',
385+
})
386+
)
387+
})
388+
271389
it('derives fallback cost from trace spans when the primary completion fails', async () => {
272390
const session = new LoggingSession('workflow-1', 'execution-6', 'api', 'req-1') as any
273391

apps/sim/lib/logs/execution/logging-session.ts

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import type {
3030
WorkflowState,
3131
} from '@/lib/logs/types'
3232
import type { SerializableExecutionState } from '@/executor/execution/types'
33+
import type { EnvironmentSecretSanitizer } from '@/executor/utils/environment-secret-sanitizer'
3334

3435
type TriggerData = Record<string, unknown> & {
3536
correlation?: NonNullable<ExecutionTrigger['data']>['correlation']
@@ -86,6 +87,8 @@ const logger = createLogger('LoggingSession')
8687

8788
type CompletionAttempt = 'complete' | 'error' | 'cancelled' | 'paused'
8889

90+
const identityEnvironmentSecretSanitizer: EnvironmentSecretSanitizer = <T>(value: T): T => value
91+
8992
export interface SessionStartParams {
9093
userId?: string
9194
/** Explicit initiating actor for callers that do not populate `userId`. */
@@ -155,6 +158,8 @@ export class LoggingSession {
155158
private completionAttemptFailed = false
156159
private pendingProgressWrites = new Set<Promise<void>>()
157160
private postExecutionPromise: Promise<void> | null = null
161+
private environmentSecretSanitizer: EnvironmentSecretSanitizer =
162+
identityEnvironmentSecretSanitizer
158163

159164
constructor(
160165
workflowId: string,
@@ -170,6 +175,19 @@ export class LoggingSession {
170175
this.requestId = requestId
171176
}
172177

178+
/**
179+
* Installs the workflow-scoped sanitizer used only for persisted and emitted
180+
* observability values. The closure is retained in memory for this session
181+
* and is never added to execution data.
182+
*/
183+
setEnvironmentSecretSanitizer(sanitizer: EnvironmentSecretSanitizer): void {
184+
this.environmentSecretSanitizer = sanitizer
185+
}
186+
187+
private sanitizeForLog<T>(value: T): T {
188+
return this.environmentSecretSanitizer(value)
189+
}
190+
173191
async onBlockStart(
174192
blockId: string,
175193
blockName: string,
@@ -287,17 +305,24 @@ export class LoggingSession {
287305
level?: 'info' | 'error'
288306
status?: 'completed' | 'failed' | 'cancelled' | 'pending'
289307
}): Promise<void> {
308+
const finalOutput = this.sanitizeForLog(params.finalOutput)
309+
const traceSpans = this.sanitizeForLog(params.traceSpans)
310+
const completionFailure =
311+
params.completionFailure === undefined
312+
? undefined
313+
: this.sanitizeForLog(params.completionFailure)
314+
290315
await executionLogger.completeWorkflowExecution({
291316
executionId: this.executionId,
292317
endedAt: params.endedAt,
293318
totalDurationMs: params.totalDurationMs,
294319
costSummary: params.costSummary,
295-
finalOutput: params.finalOutput,
296-
traceSpans: params.traceSpans,
320+
finalOutput,
321+
traceSpans,
297322
workflowInput: params.workflowInput,
298323
executionState: params.executionState,
299324
finalizationPath: params.finalizationPath,
300-
completionFailure: params.completionFailure,
325+
completionFailure,
301326
isResume: this.isResume,
302327
level: params.level,
303328
status: params.status,
@@ -403,34 +428,36 @@ export class LoggingSession {
403428
}
404429
this.completing = true
405430

406-
const { endedAt, totalDurationMs, finalOutput, traceSpans, workflowInput, executionState } =
407-
params
431+
const { endedAt, totalDurationMs, workflowInput, executionState } = params
432+
const finalOutput = this.sanitizeForLog(params.finalOutput || {})
433+
const rawTraceSpans = params.traceSpans || []
434+
const traceSpans = this.sanitizeForLog(rawTraceSpans)
408435

409436
try {
410-
const costSummary = calculateCostSummary(traceSpans || [])
437+
const costSummary = calculateCostSummary(rawTraceSpans)
411438
const endTime = endedAt || new Date().toISOString()
412439
const duration = totalDurationMs || 0
413440

414441
await this.completeExecutionWithFinalization({
415442
endedAt: endTime,
416443
totalDurationMs: duration,
417444
costSummary,
418-
finalOutput: finalOutput || {},
419-
traceSpans: traceSpans || [],
445+
finalOutput,
446+
traceSpans,
420447
workflowInput,
421448
executionState,
422449
finalizationPath: 'completed',
423450
})
424451

425452
this.completed = true
426453

427-
if (traceSpans && traceSpans.length > 0) {
454+
if (traceSpans.length > 0) {
428455
try {
429456
const { PlatformEvents, createOTelSpansForWorkflowExecution } = await import(
430457
'@/lib/core/telemetry'
431458
)
432459

433-
const hasErrors = traceSpans.some((span: any) => {
460+
const hasErrors = rawTraceSpans.some((span: any) => {
434461
const checkForErrors = (s: any): boolean => {
435462
if (s.status === 'error' && !s.errorHandled) return true
436463
if (s.children && Array.isArray(s.children)) {
@@ -506,13 +533,15 @@ export class LoggingSession {
506533
return
507534
}
508535

509-
const { endedAt, totalDurationMs, error, traceSpans, skipCost } = params
536+
const { endedAt, totalDurationMs, error, skipCost } = params
537+
const rawTraceSpans = params.traceSpans || []
538+
const traceSpans = this.sanitizeForLog(rawTraceSpans)
510539

511540
const endTime = endedAt ? new Date(endedAt) : new Date()
512541
const durationMs = typeof totalDurationMs === 'number' ? totalDurationMs : 0
513542
const startTime = new Date(endTime.getTime() - Math.max(1, durationMs))
514543

515-
const hasProvidedSpans = Array.isArray(traceSpans) && traceSpans.length > 0
544+
const hasProvidedSpans = traceSpans.length > 0
516545

517546
// calculateCostSummary([]) / (undefined) already returns the base-charge
518547
// summary, so the no-spans branch needs no separate literal.
@@ -528,9 +557,9 @@ export class LoggingSession {
528557
models: {},
529558
charges: {},
530559
}
531-
: calculateCostSummary(traceSpans)
560+
: calculateCostSummary(rawTraceSpans)
532561

533-
const message = error?.message || 'Run failed before starting blocks'
562+
const message = this.sanitizeForLog(error?.message || 'Run failed before starting blocks')
534563

535564
const errorSpan: TraceSpan = {
536565
id: 'workflow-error-root',
@@ -615,7 +644,9 @@ export class LoggingSession {
615644
this.completing = true
616645

617646
try {
618-
const { endedAt, totalDurationMs, traceSpans } = params
647+
const { endedAt, totalDurationMs } = params
648+
const rawTraceSpans = params.traceSpans || []
649+
const traceSpans = this.sanitizeForLog(rawTraceSpans)
619650

620651
const endTime = endedAt ? new Date(endedAt) : new Date()
621652
const durationMs = typeof totalDurationMs === 'number' ? totalDurationMs : 0
@@ -639,14 +670,14 @@ export class LoggingSession {
639670

640671
// calculateCostSummary handles empty/undefined spans by returning the
641672
// base-charge summary, so no separate no-spans literal is needed.
642-
const costSummary = calculateCostSummary(traceSpans)
673+
const costSummary = calculateCostSummary(rawTraceSpans)
643674

644675
await this.completeExecutionWithFinalization({
645676
endedAt: endTime.toISOString(),
646677
totalDurationMs: Math.max(1, durationMs),
647678
costSummary,
648679
finalOutput: { cancelled: true },
649-
traceSpans: traceSpans || [],
680+
traceSpans,
650681
finalizationPath: 'cancelled',
651682
status: 'cancelled',
652683
})
@@ -662,11 +693,11 @@ export class LoggingSession {
662693
durationMs: Math.max(1, durationMs),
663694
status: 'cancelled',
664695
trigger: this.triggerType,
665-
blocksExecuted: traceSpans?.length || 0,
696+
blocksExecuted: traceSpans.length,
666697
hasErrors: false,
667698
})
668699

669-
if (traceSpans && traceSpans.length > 0) {
700+
if (traceSpans.length > 0) {
670701
const startTime = new Date(endTime.getTime() - Math.max(1, durationMs))
671702
createOTelSpansForWorkflowExecution({
672703
workflowId: this.workflowId,
@@ -709,7 +740,9 @@ export class LoggingSession {
709740
this.completing = true
710741

711742
try {
712-
const { endedAt, totalDurationMs, traceSpans, workflowInput } = params
743+
const { endedAt, totalDurationMs, workflowInput } = params
744+
const rawTraceSpans = params.traceSpans || []
745+
const traceSpans = this.sanitizeForLog(rawTraceSpans)
713746

714747
const endTime = endedAt ? new Date(endedAt) : new Date()
715748
const durationMs = typeof totalDurationMs === 'number' ? totalDurationMs : 0
@@ -733,14 +766,14 @@ export class LoggingSession {
733766

734767
// calculateCostSummary handles empty/undefined spans by returning the
735768
// base-charge summary, so no separate no-spans literal is needed.
736-
const costSummary = calculateCostSummary(traceSpans)
769+
const costSummary = calculateCostSummary(rawTraceSpans)
737770

738771
await this.completeExecutionWithFinalization({
739772
endedAt: endTime.toISOString(),
740773
totalDurationMs: Math.max(1, durationMs),
741774
costSummary,
742775
finalOutput: { paused: true },
743-
traceSpans: traceSpans || [],
776+
traceSpans,
744777
workflowInput,
745778
finalizationPath: 'paused',
746779
status: 'pending',
@@ -757,12 +790,12 @@ export class LoggingSession {
757790
durationMs: Math.max(1, durationMs),
758791
status: 'paused',
759792
trigger: this.triggerType,
760-
blocksExecuted: traceSpans?.length || 0,
793+
blocksExecuted: traceSpans.length,
761794
hasErrors: false,
762795
totalCost: costSummary.totalCost || 0,
763796
})
764797

765-
if (traceSpans && traceSpans.length > 0) {
798+
if (traceSpans.length > 0) {
766799
const startTime = new Date(endTime.getTime() - Math.max(1, durationMs))
767800
createOTelSpansForWorkflowExecution({
768801
workflowId: this.workflowId,

0 commit comments

Comments
 (0)