Skip to content

Commit 50c1c67

Browse files
fix(logs): always capture cost, logging size failures (#2487)
* fix(logs): truncate strings in tracespans crashing insertion * add depth check to not crash * custom serialization to not break tracepsans * log costs even in log creation failure * code cleanup? * fix typing * remove null bytes * increase char limit * reduce char limit
1 parent df5f823 commit 50c1c67

File tree

3 files changed

+229
-57
lines changed

3 files changed

+229
-57
lines changed
Lines changed: 152 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1-
/**
2-
* Type guard to check if an object is a UserFile
3-
*/
1+
const MAX_STRING_LENGTH = 15000
2+
const MAX_DEPTH = 50
3+
4+
function truncateString(value: string, maxLength = MAX_STRING_LENGTH): string {
5+
if (value.length <= maxLength) {
6+
return value
7+
}
8+
return `${value.substring(0, maxLength)}... [truncated ${value.length - maxLength} chars]`
9+
}
10+
411
export function isUserFile(candidate: unknown): candidate is {
512
id: string
613
name: string
@@ -23,11 +30,6 @@ export function isUserFile(candidate: unknown): candidate is {
2330
)
2431
}
2532

26-
/**
27-
* Filter function that transforms UserFile objects for display
28-
* Removes internal fields: key, context
29-
* Keeps user-friendly fields: id, name, url, size, type
30-
*/
3133
function filterUserFile(data: any): any {
3234
if (isUserFile(data)) {
3335
const { id, name, url, size, type } = data
@@ -36,50 +38,152 @@ function filterUserFile(data: any): any {
3638
return data
3739
}
3840

39-
/**
40-
* Registry of filter functions to apply to data for cleaner display in logs/console.
41-
* Add new filter functions here to handle additional data types.
42-
*/
43-
const DISPLAY_FILTERS = [
44-
filterUserFile,
45-
// Add more filters here as needed
46-
]
47-
48-
/**
49-
* Generic helper to filter internal/technical fields from data for cleaner display in logs and console.
50-
* Applies all registered filters recursively to the data structure.
51-
*
52-
* To add a new filter:
53-
* 1. Create a filter function that checks and transforms a specific data type
54-
* 2. Add it to the DISPLAY_FILTERS array above
55-
*
56-
* @param data - Data to filter (objects, arrays, primitives)
57-
* @returns Filtered data with internal fields removed
58-
*/
41+
const DISPLAY_FILTERS = [filterUserFile]
42+
5943
export function filterForDisplay(data: any): any {
60-
if (!data || typeof data !== 'object') {
61-
return data
62-
}
44+
const seen = new WeakSet()
45+
return filterForDisplayInternal(data, seen, 0)
46+
}
6347

64-
// Apply all registered filters
65-
const filtered = data
66-
for (const filterFn of DISPLAY_FILTERS) {
67-
const result = filterFn(filtered)
68-
if (result !== filtered) {
69-
// Filter matched and transformed the data
70-
return result
48+
function getObjectType(data: unknown): string {
49+
return Object.prototype.toString.call(data).slice(8, -1)
50+
}
51+
52+
function filterForDisplayInternal(data: any, seen: WeakSet<object>, depth: number): any {
53+
try {
54+
if (data === null || data === undefined) {
55+
return data
7156
}
72-
}
7357

74-
// No filters matched - recursively filter nested structures
75-
if (Array.isArray(filtered)) {
76-
return filtered.map(filterForDisplay)
77-
}
58+
const dataType = typeof data
59+
60+
if (dataType === 'string') {
61+
// Remove null bytes which are not allowed in PostgreSQL JSONB
62+
const sanitized = data.includes('\u0000') ? data.replace(/\u0000/g, '') : data
63+
return truncateString(sanitized)
64+
}
65+
66+
if (dataType === 'number') {
67+
if (Number.isNaN(data)) {
68+
return '[NaN]'
69+
}
70+
if (!Number.isFinite(data)) {
71+
return data > 0 ? '[Infinity]' : '[-Infinity]'
72+
}
73+
return data
74+
}
75+
76+
if (dataType === 'boolean') {
77+
return data
78+
}
79+
80+
if (dataType === 'bigint') {
81+
return `[BigInt: ${data.toString()}]`
82+
}
83+
84+
if (dataType === 'symbol') {
85+
return `[Symbol: ${data.toString()}]`
86+
}
87+
88+
if (dataType === 'function') {
89+
return `[Function: ${data.name || 'anonymous'}]`
90+
}
91+
92+
if (dataType !== 'object') {
93+
return '[Unknown Type]'
94+
}
7895

79-
// Recursively filter object properties
80-
const result: any = {}
81-
for (const [key, value] of Object.entries(filtered)) {
82-
result[key] = filterForDisplay(value)
96+
if (seen.has(data)) {
97+
return '[Circular Reference]'
98+
}
99+
100+
if (depth > MAX_DEPTH) {
101+
return '[Max Depth Exceeded]'
102+
}
103+
104+
const objectType = getObjectType(data)
105+
106+
switch (objectType) {
107+
case 'Date': {
108+
const timestamp = (data as Date).getTime()
109+
if (Number.isNaN(timestamp)) {
110+
return '[Invalid Date]'
111+
}
112+
return (data as Date).toISOString()
113+
}
114+
115+
case 'RegExp':
116+
return (data as RegExp).toString()
117+
118+
case 'URL':
119+
return (data as URL).toString()
120+
121+
case 'Error': {
122+
const err = data as Error
123+
return {
124+
name: err.name,
125+
message: truncateString(err.message),
126+
stack: err.stack ? truncateString(err.stack) : undefined,
127+
}
128+
}
129+
130+
case 'ArrayBuffer':
131+
return `[ArrayBuffer: ${(data as ArrayBuffer).byteLength} bytes]`
132+
133+
case 'Map': {
134+
const obj: Record<string, any> = {}
135+
for (const [key, value] of (data as Map<any, any>).entries()) {
136+
const keyStr = typeof key === 'string' ? key : String(key)
137+
obj[keyStr] = filterForDisplayInternal(value, seen, depth + 1)
138+
}
139+
return obj
140+
}
141+
142+
case 'Set':
143+
return Array.from(data as Set<any>).map((item) =>
144+
filterForDisplayInternal(item, seen, depth + 1)
145+
)
146+
147+
case 'WeakMap':
148+
return '[WeakMap]'
149+
150+
case 'WeakSet':
151+
return '[WeakSet]'
152+
153+
case 'WeakRef':
154+
return '[WeakRef]'
155+
156+
case 'Promise':
157+
return '[Promise]'
158+
}
159+
160+
if (ArrayBuffer.isView(data)) {
161+
return `[${objectType}: ${(data as ArrayBufferView).byteLength} bytes]`
162+
}
163+
164+
seen.add(data)
165+
166+
for (const filterFn of DISPLAY_FILTERS) {
167+
const result = filterFn(data)
168+
if (result !== data) {
169+
return filterForDisplayInternal(result, seen, depth + 1)
170+
}
171+
}
172+
173+
if (Array.isArray(data)) {
174+
return data.map((item) => filterForDisplayInternal(item, seen, depth + 1))
175+
}
176+
177+
const result: Record<string, any> = {}
178+
for (const key of Object.keys(data)) {
179+
try {
180+
result[key] = filterForDisplayInternal(data[key], seen, depth + 1)
181+
} catch {
182+
result[key] = '[Error accessing property]'
183+
}
184+
}
185+
return result
186+
} catch {
187+
return '[Unserializable]'
83188
}
84-
return result
85189
}

apps/sim/lib/logs/execution/logger.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
230230
traceSpans?: TraceSpan[]
231231
workflowInput?: any
232232
isResume?: boolean // If true, merge with existing data instead of replacing
233+
level?: 'info' | 'error' // Optional override for log level (used in cost-only fallback)
233234
}): Promise<WorkflowExecutionLog> {
234235
const {
235236
executionId,
@@ -240,6 +241,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
240241
traceSpans,
241242
workflowInput,
242243
isResume,
244+
level: levelOverride,
243245
} = params
244246

245247
logger.debug(`Completing workflow execution ${executionId}`, { isResume })
@@ -256,6 +258,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
256258
}
257259

258260
// Determine if workflow failed by checking trace spans for errors
261+
// Use the override if provided (for cost-only fallback scenarios)
259262
const hasErrors = traceSpans?.some((span: any) => {
260263
const checkSpanForErrors = (s: any): boolean => {
261264
if (s.status === 'error') return true
@@ -267,7 +270,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
267270
return checkSpanForErrors(span)
268271
})
269272

270-
const level = hasErrors ? 'error' : 'info'
273+
const level = levelOverride ?? (hasErrors ? 'error' : 'info')
271274

272275
// Extract files from trace spans, final output, and workflow input
273276
const executionFiles = this.extractFilesFromExecution(traceSpans, finalOutput, workflowInput)

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

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export interface SessionCompleteParams {
2929
endedAt?: string
3030
totalDurationMs?: number
3131
finalOutput?: any
32-
traceSpans?: any[]
32+
traceSpans?: TraceSpan[]
3333
workflowInput?: any
3434
}
3535

@@ -331,20 +331,85 @@ export class LoggingSession {
331331
try {
332332
await this.complete(params)
333333
} catch (error) {
334-
// Error already logged in complete(), log a summary here
334+
const errorMsg = error instanceof Error ? error.message : String(error)
335335
logger.warn(
336-
`[${this.requestId || 'unknown'}] Logging completion failed for execution ${this.executionId} - execution data not persisted`
336+
`[${this.requestId || 'unknown'}] Complete failed for execution ${this.executionId}, attempting fallback`,
337+
{ error: errorMsg }
337338
)
339+
await this.completeWithCostOnlyLog({
340+
traceSpans: params.traceSpans,
341+
endedAt: params.endedAt,
342+
totalDurationMs: params.totalDurationMs,
343+
errorMessage: `Failed to store trace spans: ${errorMsg}`,
344+
isError: false,
345+
})
338346
}
339347
}
340348

341-
async safeCompleteWithError(error?: SessionErrorCompleteParams): Promise<void> {
349+
async safeCompleteWithError(params?: SessionErrorCompleteParams): Promise<void> {
342350
try {
343-
await this.completeWithError(error)
344-
} catch (enhancedError) {
345-
// Error already logged in completeWithError(), log a summary here
351+
await this.completeWithError(params)
352+
} catch (error) {
353+
const errorMsg = error instanceof Error ? error.message : String(error)
346354
logger.warn(
347-
`[${this.requestId || 'unknown'}] Error logging completion failed for execution ${this.executionId} - execution data not persisted`
355+
`[${this.requestId || 'unknown'}] CompleteWithError failed for execution ${this.executionId}, attempting fallback`,
356+
{ error: errorMsg }
357+
)
358+
await this.completeWithCostOnlyLog({
359+
traceSpans: params?.traceSpans,
360+
endedAt: params?.endedAt,
361+
totalDurationMs: params?.totalDurationMs,
362+
errorMessage:
363+
params?.error?.message || `Execution failed to store trace spans: ${errorMsg}`,
364+
isError: true,
365+
})
366+
}
367+
}
368+
369+
private async completeWithCostOnlyLog(params: {
370+
traceSpans?: TraceSpan[]
371+
endedAt?: string
372+
totalDurationMs?: number
373+
errorMessage: string
374+
isError: boolean
375+
}): Promise<void> {
376+
logger.warn(
377+
`[${this.requestId || 'unknown'}] Logging completion failed for execution ${this.executionId} - attempting cost-only fallback`
378+
)
379+
380+
try {
381+
const costSummary = params.traceSpans?.length
382+
? calculateCostSummary(params.traceSpans)
383+
: {
384+
totalCost: BASE_EXECUTION_CHARGE,
385+
totalInputCost: 0,
386+
totalOutputCost: 0,
387+
totalTokens: 0,
388+
totalPromptTokens: 0,
389+
totalCompletionTokens: 0,
390+
baseExecutionCharge: BASE_EXECUTION_CHARGE,
391+
modelCost: 0,
392+
models: {},
393+
}
394+
395+
await executionLogger.completeWorkflowExecution({
396+
executionId: this.executionId,
397+
endedAt: params.endedAt || new Date().toISOString(),
398+
totalDurationMs: params.totalDurationMs || 0,
399+
costSummary,
400+
finalOutput: { _fallback: true, error: params.errorMessage },
401+
traceSpans: [],
402+
isResume: this.isResume,
403+
level: params.isError ? 'error' : 'info',
404+
})
405+
406+
logger.info(
407+
`[${this.requestId || 'unknown'}] Cost-only fallback succeeded for execution ${this.executionId}`
408+
)
409+
} catch (fallbackError) {
410+
logger.error(
411+
`[${this.requestId || 'unknown'}] Cost-only fallback also failed for execution ${this.executionId}:`,
412+
{ error: fallbackError instanceof Error ? fallbackError.message : String(fallbackError) }
348413
)
349414
}
350415
}

0 commit comments

Comments
 (0)