/** * Error Execution Processor Service * * Specialized processor for extracting error context from failed n8n executions. * Designed for AI agent debugging workflows with token efficiency. * * Features: * - Auto-identify error nodes * - Extract upstream context (input data to error node) * - Build execution path from trigger to error * - Generate AI-friendly fix suggestions */ import { Execution, Workflow, ErrorAnalysis, ErrorSuggestion, } from '../types/n8n-api'; import { logger } from '../utils/logger'; /** * Options for error processing */ export interface ErrorProcessorOptions { itemsLimit?: number; // Default: 2 includeStackTrace?: boolean; // Default: false includeExecutionPath?: boolean; // Default: true workflow?: Workflow; // Optional: for accurate upstream detection } // Constants const MAX_STACK_LINES = 3; /** * Keys that could enable prototype pollution attacks * These are blocked entirely from processing */ const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']); /** * Patterns for sensitive data that should be masked in output * Expanded from code review recommendations */ const SENSITIVE_PATTERNS = [ 'password', 'secret', 'token', 'apikey', 'api_key', 'credential', 'auth', 'private_key', 'privatekey', 'bearer', 'jwt', 'oauth', 'certificate', 'passphrase', 'access_token', 'refresh_token', 'session', 'cookie', 'authorization' ]; /** * Process execution for error debugging */ export function processErrorExecution( execution: Execution, options: ErrorProcessorOptions = {} ): ErrorAnalysis { const { itemsLimit = 2, includeStackTrace = false, includeExecutionPath = true, workflow } = options; const resultData = execution.data?.resultData; const error = resultData?.error as Record | undefined; const runData = resultData?.runData as Record || {}; const lastNode = resultData?.lastNodeExecuted; // 1. Extract primary error info const primaryError = extractPrimaryError(error, lastNode, runData, includeStackTrace); // 2. Find and extract upstream context const upstreamContext = extractUpstreamContext( primaryError.nodeName, runData, workflow, itemsLimit ); // 3. Build execution path if requested const executionPath = includeExecutionPath ? buildExecutionPath(primaryError.nodeName, runData, workflow) : undefined; // 4. Find additional errors (for batch failures) const additionalErrors = findAdditionalErrors( primaryError.nodeName, runData ); // 5. Generate AI suggestions const suggestions = generateSuggestions(primaryError, upstreamContext); return { primaryError, upstreamContext, executionPath, additionalErrors: additionalErrors.length > 0 ? additionalErrors : undefined, suggestions: suggestions.length > 0 ? suggestions : undefined }; } /** * Extract primary error information */ function extractPrimaryError( error: Record | undefined, lastNode: string | undefined, runData: Record, includeFullStackTrace: boolean ): ErrorAnalysis['primaryError'] { // Error info from resultData.error const errorNode = error?.node as Record | undefined; const nodeName = (errorNode?.name as string) || lastNode || 'Unknown'; // Also check runData for node-level errors const nodeRunData = runData[nodeName]; const nodeError = nodeRunData?.[0]?.error; const stackTrace = (error?.stack || nodeError?.stack) as string | undefined; return { message: (error?.message || nodeError?.message || 'Unknown error') as string, errorType: (error?.name || nodeError?.name || 'Error') as string, nodeName, nodeType: (errorNode?.type || '') as string, nodeId: errorNode?.id as string | undefined, nodeParameters: extractRelevantParameters(errorNode?.parameters), stackTrace: includeFullStackTrace ? stackTrace : truncateStackTrace(stackTrace) }; } /** * Extract upstream context (input data to error node) */ function extractUpstreamContext( errorNodeName: string, runData: Record, workflow?: Workflow, itemsLimit: number = 2 ): ErrorAnalysis['upstreamContext'] | undefined { // Strategy 1: Use workflow connections if available if (workflow) { const upstreamNode = findUpstreamNode(errorNodeName, workflow); if (upstreamNode) { const context = extractNodeOutput(upstreamNode, runData, itemsLimit); if (context) { // Enrich with node type from workflow const nodeInfo = workflow.nodes.find(n => n.name === upstreamNode); if (nodeInfo) { context.nodeType = nodeInfo.type; } return context; } } } // Strategy 2: Heuristic - find node that produced data most recently before error const successfulNodes = Object.entries(runData) .filter(([name, data]) => { if (name === errorNodeName) return false; const runs = data as any[]; return runs?.[0]?.data?.main?.[0]?.length > 0 && !runs?.[0]?.error; }) .map(([name, data]) => ({ name, executionTime: (data as any[])?.[0]?.executionTime || 0, startTime: (data as any[])?.[0]?.startTime || 0 })) .sort((a, b) => b.startTime - a.startTime); if (successfulNodes.length > 0) { const upstreamName = successfulNodes[0].name; return extractNodeOutput(upstreamName, runData, itemsLimit); } return undefined; } /** * Find upstream node using workflow connections * Connections format: { sourceNode: { main: [[{node: targetNode, type, index}]] } } */ function findUpstreamNode( targetNode: string, workflow: Workflow ): string | undefined { for (const [sourceName, outputs] of Object.entries(workflow.connections)) { const connections = outputs as Record; const mainOutputs = connections?.main || []; for (const outputBranch of mainOutputs) { if (!Array.isArray(outputBranch)) continue; for (const connection of outputBranch) { if (connection?.node === targetNode) { return sourceName; } } } } return undefined; } /** * Find all upstream nodes (for building complete path) */ function findAllUpstreamNodes( targetNode: string, workflow: Workflow, visited: Set = new Set() ): string[] { const path: string[] = []; let currentNode = targetNode; while (currentNode && !visited.has(currentNode)) { visited.add(currentNode); const upstream = findUpstreamNode(currentNode, workflow); if (upstream) { path.unshift(upstream); currentNode = upstream; } else { break; } } return path; } /** * Extract node output with sampling and sanitization */ function extractNodeOutput( nodeName: string, runData: Record, itemsLimit: number ): ErrorAnalysis['upstreamContext'] | undefined { const nodeData = runData[nodeName]; if (!nodeData?.[0]?.data?.main?.[0]) return undefined; const items = nodeData[0].data.main[0]; // Sanitize sample items to remove sensitive data const rawSamples = items.slice(0, itemsLimit); const sanitizedSamples = rawSamples.map((item: unknown) => sanitizeData(item)); return { nodeName, nodeType: '', // Will be enriched if workflow available itemCount: items.length, sampleItems: sanitizedSamples, dataStructure: extractStructure(items[0]) }; } /** * Build execution path leading to error */ function buildExecutionPath( errorNodeName: string, runData: Record, workflow?: Workflow ): ErrorAnalysis['executionPath'] { const path: ErrorAnalysis['executionPath'] = []; // If we have workflow, trace connections backward for ordered path if (workflow) { const upstreamNodes = findAllUpstreamNodes(errorNodeName, workflow); // Add upstream nodes for (const nodeName of upstreamNodes) { const nodeData = runData[nodeName]; const runs = nodeData as any[] | undefined; const hasError = runs?.[0]?.error; const itemCount = runs?.[0]?.data?.main?.[0]?.length || 0; path.push({ nodeName, status: hasError ? 'error' : (runs ? 'success' : 'skipped'), itemCount, executionTime: runs?.[0]?.executionTime }); } // Add error node const errorNodeData = runData[errorNodeName]; path.push({ nodeName: errorNodeName, status: 'error', itemCount: 0, executionTime: errorNodeData?.[0]?.executionTime }); } else { // Without workflow, list all executed nodes by execution order (best effort) const nodesByTime = Object.entries(runData) .map(([name, data]) => ({ name, data: data as any[], startTime: (data as any[])?.[0]?.startTime || 0 })) .sort((a, b) => a.startTime - b.startTime); for (const { name, data } of nodesByTime) { path.push({ nodeName: name, status: data?.[0]?.error ? 'error' : 'success', itemCount: data?.[0]?.data?.main?.[0]?.length || 0, executionTime: data?.[0]?.executionTime }); } } return path; } /** * Find additional error nodes (for batch/parallel failures) */ function findAdditionalErrors( primaryErrorNode: string, runData: Record ): Array<{ nodeName: string; message: string }> { const additional: Array<{ nodeName: string; message: string }> = []; for (const [nodeName, data] of Object.entries(runData)) { if (nodeName === primaryErrorNode) continue; const runs = data as any[]; const error = runs?.[0]?.error; if (error) { additional.push({ nodeName, message: error.message || 'Unknown error' }); } } return additional; } /** * Generate AI-friendly error suggestions based on patterns */ function generateSuggestions( error: ErrorAnalysis['primaryError'], upstream?: ErrorAnalysis['upstreamContext'] ): ErrorSuggestion[] { const suggestions: ErrorSuggestion[] = []; const message = error.message.toLowerCase(); // Pattern: Missing required field if (message.includes('required') || message.includes('must be provided') || message.includes('is required')) { suggestions.push({ type: 'fix', title: 'Missing Required Field', description: `Check "${error.nodeName}" parameters for required fields. Error indicates a mandatory value is missing.`, confidence: 'high' }); } // Pattern: Empty input if (upstream?.itemCount === 0) { suggestions.push({ type: 'investigate', title: 'No Input Data', description: `"${error.nodeName}" received 0 items from "${upstream.nodeName}". Check upstream node's filtering or data source.`, confidence: 'high' }); } // Pattern: Authentication error if (message.includes('auth') || message.includes('credentials') || message.includes('401') || message.includes('unauthorized') || message.includes('forbidden') || message.includes('403')) { suggestions.push({ type: 'fix', title: 'Authentication Issue', description: 'Verify credentials are configured correctly. Check API key permissions and expiration.', confidence: 'high' }); } // Pattern: Rate limiting if (message.includes('rate limit') || message.includes('429') || message.includes('too many requests') || message.includes('throttle')) { suggestions.push({ type: 'workaround', title: 'Rate Limited', description: 'Add delay between requests or reduce batch size. Consider using retry with exponential backoff.', confidence: 'high' }); } // Pattern: Connection error if (message.includes('econnrefused') || message.includes('enotfound') || message.includes('etimedout') || message.includes('network') || message.includes('connect')) { suggestions.push({ type: 'investigate', title: 'Network/Connection Error', description: 'Check if the external service is reachable. Verify URL, firewall rules, and DNS resolution.', confidence: 'high' }); } // Pattern: Invalid JSON if (message.includes('json') || message.includes('parse error') || message.includes('unexpected token') || message.includes('syntax error')) { suggestions.push({ type: 'fix', title: 'Invalid JSON Format', description: 'Check the data format. Ensure JSON is properly structured with correct syntax.', confidence: 'high' }); } // Pattern: Field not found / invalid path if (message.includes('not found') || message.includes('undefined') || message.includes('cannot read property') || message.includes('does not exist')) { suggestions.push({ type: 'investigate', title: 'Missing Data Field', description: 'A referenced field does not exist in the input data. Check data structure and field names.', confidence: 'medium' }); } // Pattern: Type error if (message.includes('type') && (message.includes('expected') || message.includes('invalid'))) { suggestions.push({ type: 'fix', title: 'Data Type Mismatch', description: 'Input data type does not match expected type. Check if strings/numbers/arrays are used correctly.', confidence: 'medium' }); } // Pattern: Timeout if (message.includes('timeout') || message.includes('timed out')) { suggestions.push({ type: 'workaround', title: 'Operation Timeout', description: 'The operation took too long. Consider increasing timeout, reducing data size, or optimizing the query.', confidence: 'high' }); } // Pattern: Permission denied if (message.includes('permission') || message.includes('access denied') || message.includes('not allowed')) { suggestions.push({ type: 'fix', title: 'Permission Denied', description: 'The operation lacks required permissions. Check user roles, API scopes, or resource access settings.', confidence: 'high' }); } // Generic NodeOperationError guidance if (error.errorType === 'NodeOperationError' && suggestions.length === 0) { suggestions.push({ type: 'investigate', title: 'Node Configuration Issue', description: `Review "${error.nodeName}" parameters and operation settings. Validate against the node's requirements.`, confidence: 'medium' }); } return suggestions; } // Helper functions /** * Check if a key contains sensitive patterns */ function isSensitiveKey(key: string): boolean { const lowerKey = key.toLowerCase(); return SENSITIVE_PATTERNS.some(pattern => lowerKey.includes(pattern)); } /** * Recursively sanitize data by removing dangerous keys and masking sensitive values * * @param data - The data to sanitize * @param depth - Current recursion depth * @param maxDepth - Maximum recursion depth (default: 10) * @returns Sanitized data with sensitive values masked */ function sanitizeData(data: unknown, depth = 0, maxDepth = 10): unknown { // Prevent infinite recursion if (depth >= maxDepth) { return '[max depth reached]'; } // Handle null/undefined if (data === null || data === undefined) { return data; } // Handle primitives if (typeof data !== 'object') { // Truncate long strings if (typeof data === 'string' && data.length > 500) { return '[truncated]'; } return data; } // Handle arrays if (Array.isArray(data)) { return data.map(item => sanitizeData(item, depth + 1, maxDepth)); } // Handle objects const sanitized: Record = {}; const obj = data as Record; for (const [key, value] of Object.entries(obj)) { // Block prototype pollution attempts if (DANGEROUS_KEYS.has(key)) { logger.warn(`Blocked potentially dangerous key: ${key}`); continue; } // Mask sensitive fields if (isSensitiveKey(key)) { sanitized[key] = '[REDACTED]'; continue; } // Recursively sanitize nested values sanitized[key] = sanitizeData(value, depth + 1, maxDepth); } return sanitized; } /** * Extract relevant parameters (filtering sensitive data) */ function extractRelevantParameters(params: unknown): Record | undefined { if (!params || typeof params !== 'object') return undefined; const sanitized = sanitizeData(params); if (!sanitized || typeof sanitized !== 'object' || Array.isArray(sanitized)) { return undefined; } return Object.keys(sanitized).length > 0 ? sanitized as Record : undefined; } /** * Truncate stack trace to first few lines */ function truncateStackTrace(stack?: string): string | undefined { if (!stack) return undefined; const lines = stack.split('\n'); if (lines.length <= MAX_STACK_LINES) return stack; return lines.slice(0, MAX_STACK_LINES).join('\n') + `\n... (${lines.length - MAX_STACK_LINES} more lines)`; } /** * Extract data structure from an item */ function extractStructure(item: unknown, depth = 0, maxDepth = 3): Record { if (depth >= maxDepth) return { _type: typeof item }; if (item === null || item === undefined) { return { _type: 'null' }; } if (Array.isArray(item)) { if (item.length === 0) return { _type: 'array', _length: 0 }; return { _type: 'array', _length: item.length, _itemStructure: extractStructure(item[0], depth + 1, maxDepth) }; } if (typeof item === 'object') { const structure: Record = {}; for (const [key, value] of Object.entries(item)) { structure[key] = extractStructure(value, depth + 1, maxDepth); } return structure; } return { _type: typeof item }; }