mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-01-30 06:22:04 +00:00
feat: add anonymous telemetry system with Supabase integration
- Implement telemetry manager for tracking tool usage and workflows - Add workflow sanitizer to remove sensitive data before storage - Create config manager with opt-in/opt-out mechanism - Integrate telemetry tracking into MCP server and workflow handlers - Add CLI commands for telemetry control (enable/disable/status) - Show first-run notice with clear privacy information - Add comprehensive unit tests for sanitization and config - Track tool usage metrics, workflow patterns, and errors - Ensure complete anonymity with deterministic user IDs - Never collect URLs, API keys, or sensitive information
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -130,3 +130,6 @@ n8n-mcp-wrapper.sh
|
|||||||
|
|
||||||
# MCP configuration files
|
# MCP configuration files
|
||||||
.mcp.json
|
.mcp.json
|
||||||
|
|
||||||
|
# Telemetry configuration (user-specific)
|
||||||
|
~/.n8n-mcp/
|
||||||
|
|||||||
113
package-lock.json
generated
113
package-lock.json
generated
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.12.1",
|
"version": "2.13.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.12.1",
|
"version": "2.13.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.13.2",
|
"@modelcontextprotocol/sdk": "^1.13.2",
|
||||||
"@n8n/n8n-nodes-langchain": "^1.111.1",
|
"@n8n/n8n-nodes-langchain": "^1.111.1",
|
||||||
|
"@supabase/supabase-js": "^2.57.4",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"lru-cache": "^11.2.1",
|
"lru-cache": "^11.2.1",
|
||||||
@@ -12328,6 +12329,68 @@
|
|||||||
"@opentelemetry/semantic-conventions": "^1.28.0"
|
"@opentelemetry/semantic-conventions": "^1.28.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@n8n/n8n-nodes-langchain/node_modules/@supabase/auth-js": {
|
||||||
|
"version": "2.69.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.69.1.tgz",
|
||||||
|
"integrity": "sha512-FILtt5WjCNzmReeRLq5wRs3iShwmnWgBvxHfqapC/VoljJl+W8hDAyFmf1NVw3zH+ZjZ05AKxiKxVeb0HNWRMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/node-fetch": "^2.6.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@n8n/n8n-nodes-langchain/node_modules/@supabase/functions-js": {
|
||||||
|
"version": "2.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.4.tgz",
|
||||||
|
"integrity": "sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/node-fetch": "^2.6.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@n8n/n8n-nodes-langchain/node_modules/@supabase/postgrest-js": {
|
||||||
|
"version": "1.19.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.4.tgz",
|
||||||
|
"integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/node-fetch": "^2.6.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@n8n/n8n-nodes-langchain/node_modules/@supabase/realtime-js": {
|
||||||
|
"version": "2.11.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.11.9.tgz",
|
||||||
|
"integrity": "sha512-fLseWq8tEPCO85x3TrV9Hqvk7H4SGOqnFQ223NPJSsxjSYn0EmzU1lvYO6wbA0fc8DE94beCAiiWvGvo4g33lQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/node-fetch": "^2.6.13",
|
||||||
|
"@types/phoenix": "^1.6.6",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"ws": "^8.18.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@n8n/n8n-nodes-langchain/node_modules/@supabase/storage-js": {
|
||||||
|
"version": "2.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz",
|
||||||
|
"integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/node-fetch": "^2.6.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@n8n/n8n-nodes-langchain/node_modules/@supabase/supabase-js": {
|
||||||
|
"version": "2.49.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.49.9.tgz",
|
||||||
|
"integrity": "sha512-lB2A2X8k1aWAqvlpO4uZOdfvSuZ2s0fCMwJ1Vq6tjWsi3F+au5lMbVVn92G0pG8gfmis33d64Plkm6eSDs6jRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/auth-js": "2.69.1",
|
||||||
|
"@supabase/functions-js": "2.4.4",
|
||||||
|
"@supabase/node-fetch": "2.6.15",
|
||||||
|
"@supabase/postgrest-js": "1.19.4",
|
||||||
|
"@supabase/realtime-js": "2.11.9",
|
||||||
|
"@supabase/storage-js": "2.7.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@n8n/n8n-nodes-langchain/node_modules/@types/connect": {
|
"node_modules/@n8n/n8n-nodes-langchain/node_modules/@types/connect": {
|
||||||
"version": "3.4.36",
|
"version": "3.4.36",
|
||||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.36.tgz",
|
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.36.tgz",
|
||||||
@@ -15647,18 +15710,18 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/auth-js": {
|
"node_modules/@supabase/auth-js": {
|
||||||
"version": "2.69.1",
|
"version": "2.71.1",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.69.1.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz",
|
||||||
"integrity": "sha512-FILtt5WjCNzmReeRLq5wRs3iShwmnWgBvxHfqapC/VoljJl+W8hDAyFmf1NVw3zH+ZjZ05AKxiKxVeb0HNWRMQ==",
|
"integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/node-fetch": "^2.6.14"
|
"@supabase/node-fetch": "^2.6.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/functions-js": {
|
"node_modules/@supabase/functions-js": {
|
||||||
"version": "2.4.4",
|
"version": "2.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.6.tgz",
|
||||||
"integrity": "sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==",
|
"integrity": "sha512-bhjZ7rmxAibjgmzTmQBxJU6ZIBCCJTc3Uwgvdi4FewueUTAGO5hxZT1Sj6tiD+0dSXf9XI87BDdJrg12z8Uaew==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/node-fetch": "^2.6.14"
|
"@supabase/node-fetch": "^2.6.14"
|
||||||
@@ -15677,18 +15740,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/postgrest-js": {
|
"node_modules/@supabase/postgrest-js": {
|
||||||
"version": "1.19.4",
|
"version": "1.21.4",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.4.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.21.4.tgz",
|
||||||
"integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==",
|
"integrity": "sha512-TxZCIjxk6/dP9abAi89VQbWWMBbybpGWyvmIzTd79OeravM13OjR/YEYeyUOPcM1C3QyvXkvPZhUfItvmhY1IQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/node-fetch": "^2.6.14"
|
"@supabase/node-fetch": "^2.6.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/realtime-js": {
|
"node_modules/@supabase/realtime-js": {
|
||||||
"version": "2.11.9",
|
"version": "2.15.5",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.11.9.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.15.5.tgz",
|
||||||
"integrity": "sha512-fLseWq8tEPCO85x3TrV9Hqvk7H4SGOqnFQ223NPJSsxjSYn0EmzU1lvYO6wbA0fc8DE94beCAiiWvGvo4g33lQ==",
|
"integrity": "sha512-/Rs5Vqu9jejRD8ZeuaWXebdkH+J7V6VySbCZ/zQM93Ta5y3mAmocjioa/nzlB6qvFmyylUgKVS1KpE212t30OA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/node-fetch": "^2.6.13",
|
"@supabase/node-fetch": "^2.6.13",
|
||||||
@@ -15698,26 +15761,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/storage-js": {
|
"node_modules/@supabase/storage-js": {
|
||||||
"version": "2.7.1",
|
"version": "2.12.1",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.12.1.tgz",
|
||||||
"integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==",
|
"integrity": "sha512-QWg3HV6Db2J81VQx0PqLq0JDBn4Q8B1FYn1kYcbla8+d5WDmTdwwMr+EJAxNOSs9W4mhKMv+EYCpCrTFlTj4VQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/node-fetch": "^2.6.14"
|
"@supabase/node-fetch": "^2.6.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/supabase-js": {
|
"node_modules/@supabase/supabase-js": {
|
||||||
"version": "2.49.9",
|
"version": "2.57.4",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.49.9.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.57.4.tgz",
|
||||||
"integrity": "sha512-lB2A2X8k1aWAqvlpO4uZOdfvSuZ2s0fCMwJ1Vq6tjWsi3F+au5lMbVVn92G0pG8gfmis33d64Plkm6eSDs6jRA==",
|
"integrity": "sha512-LcbTzFhHYdwfQ7TRPfol0z04rLEyHabpGYANME6wkQ/kLtKNmI+Vy+WEM8HxeOZAtByUFxoUTTLwhXmrh+CcVw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/auth-js": "2.69.1",
|
"@supabase/auth-js": "2.71.1",
|
||||||
"@supabase/functions-js": "2.4.4",
|
"@supabase/functions-js": "2.4.6",
|
||||||
"@supabase/node-fetch": "2.6.15",
|
"@supabase/node-fetch": "2.6.15",
|
||||||
"@supabase/postgrest-js": "1.19.4",
|
"@supabase/postgrest-js": "1.21.4",
|
||||||
"@supabase/realtime-js": "2.11.9",
|
"@supabase/realtime-js": "2.15.5",
|
||||||
"@supabase/storage-js": "2.7.1"
|
"@supabase/storage-js": "2.12.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supercharge/promise-pool": {
|
"node_modules/@supercharge/promise-pool": {
|
||||||
|
|||||||
@@ -129,6 +129,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.13.2",
|
"@modelcontextprotocol/sdk": "^1.13.2",
|
||||||
"@n8n/n8n-nodes-langchain": "^1.111.1",
|
"@n8n/n8n-nodes-langchain": "^1.111.1",
|
||||||
|
"@supabase/supabase-js": "^2.57.4",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"lru-cache": "^11.2.1",
|
"lru-cache": "^11.2.1",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { InstanceContext, validateInstanceContext } from '../types/instance-cont
|
|||||||
import { WorkflowAutoFixer, AutoFixConfig } from '../services/workflow-auto-fixer';
|
import { WorkflowAutoFixer, AutoFixConfig } from '../services/workflow-auto-fixer';
|
||||||
import { ExpressionFormatValidator } from '../services/expression-format-validator';
|
import { ExpressionFormatValidator } from '../services/expression-format-validator';
|
||||||
import { handleUpdatePartialWorkflow } from './handlers-workflow-diff';
|
import { handleUpdatePartialWorkflow } from './handlers-workflow-diff';
|
||||||
|
import { telemetry } from '../telemetry';
|
||||||
import {
|
import {
|
||||||
createCacheKey,
|
createCacheKey,
|
||||||
createInstanceCache,
|
createInstanceCache,
|
||||||
@@ -280,16 +281,22 @@ export async function handleCreateWorkflow(args: unknown, context?: InstanceCont
|
|||||||
// Validate workflow structure
|
// Validate workflow structure
|
||||||
const errors = validateWorkflowStructure(input);
|
const errors = validateWorkflowStructure(input);
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
|
// Track validation failure
|
||||||
|
telemetry.trackWorkflowCreation(input, false);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Workflow validation failed',
|
error: 'Workflow validation failed',
|
||||||
details: { errors }
|
details: { errors }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create workflow
|
// Create workflow
|
||||||
const workflow = await client.createWorkflow(input);
|
const workflow = await client.createWorkflow(input);
|
||||||
|
|
||||||
|
// Track successful workflow creation
|
||||||
|
telemetry.trackWorkflowCreation(workflow, true);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: workflow,
|
data: workflow,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { N8NDocumentationMCPServer } from './server';
|
import { N8NDocumentationMCPServer } from './server';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
import { TelemetryConfigManager } from '../telemetry/config-manager';
|
||||||
|
|
||||||
// Add error details to stderr for Claude Desktop debugging
|
// Add error details to stderr for Claude Desktop debugging
|
||||||
process.on('uncaughtException', (error) => {
|
process.on('uncaughtException', (error) => {
|
||||||
@@ -21,8 +22,42 @@ process.on('unhandledRejection', (reason, promise) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
// Handle telemetry CLI commands
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.length > 0 && args[0] === 'telemetry') {
|
||||||
|
const telemetryConfig = TelemetryConfigManager.getInstance();
|
||||||
|
const action = args[1];
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'enable':
|
||||||
|
telemetryConfig.enable();
|
||||||
|
process.exit(0);
|
||||||
|
break;
|
||||||
|
case 'disable':
|
||||||
|
telemetryConfig.disable();
|
||||||
|
process.exit(0);
|
||||||
|
break;
|
||||||
|
case 'status':
|
||||||
|
console.log(telemetryConfig.getStatus());
|
||||||
|
process.exit(0);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log(`
|
||||||
|
Usage: n8n-mcp telemetry [command]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
enable Enable anonymous telemetry
|
||||||
|
disable Disable anonymous telemetry
|
||||||
|
status Show current telemetry status
|
||||||
|
|
||||||
|
Learn more: https://github.com/czlonkowski/n8n-mcp/privacy
|
||||||
|
`);
|
||||||
|
process.exit(args[1] ? 1 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mode = process.env.MCP_MODE || 'stdio';
|
const mode = process.env.MCP_MODE || 'stdio';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Only show debug messages in HTTP mode to avoid corrupting stdio communication
|
// Only show debug messages in HTTP mode to avoid corrupting stdio communication
|
||||||
if (mode === 'http') {
|
if (mode === 'http') {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
STANDARD_PROTOCOL_VERSION
|
STANDARD_PROTOCOL_VERSION
|
||||||
} from '../utils/protocol-version';
|
} from '../utils/protocol-version';
|
||||||
import { InstanceContext } from '../types/instance-context';
|
import { InstanceContext } from '../types/instance-context';
|
||||||
|
import { telemetry } from '../telemetry';
|
||||||
|
|
||||||
interface NodeRow {
|
interface NodeRow {
|
||||||
node_type: string;
|
node_type: string;
|
||||||
@@ -180,7 +181,10 @@ export class N8NDocumentationMCPServer {
|
|||||||
clientCapabilities,
|
clientCapabilities,
|
||||||
clientInfo
|
clientInfo
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track session start
|
||||||
|
telemetry.trackSessionStart();
|
||||||
|
|
||||||
// Store client info for later use
|
// Store client info for later use
|
||||||
this.clientInfo = clientInfo;
|
this.clientInfo = clientInfo;
|
||||||
|
|
||||||
@@ -322,8 +326,13 @@ export class N8NDocumentationMCPServer {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.debug(`Executing tool: ${name}`, { args: processedArgs });
|
logger.debug(`Executing tool: ${name}`, { args: processedArgs });
|
||||||
|
const startTime = Date.now();
|
||||||
const result = await this.executeTool(name, processedArgs);
|
const result = await this.executeTool(name, processedArgs);
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
logger.debug(`Tool ${name} executed successfully`);
|
logger.debug(`Tool ${name} executed successfully`);
|
||||||
|
|
||||||
|
// Track tool usage
|
||||||
|
telemetry.trackToolUsage(name, true, duration);
|
||||||
|
|
||||||
// Ensure the result is properly formatted for MCP
|
// Ensure the result is properly formatted for MCP
|
||||||
let responseText: string;
|
let responseText: string;
|
||||||
@@ -370,7 +379,15 @@ export class N8NDocumentationMCPServer {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error executing tool ${name}`, error);
|
logger.error(`Error executing tool ${name}`, error);
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|
||||||
|
// Track tool error
|
||||||
|
telemetry.trackToolUsage(name, false);
|
||||||
|
telemetry.trackError(
|
||||||
|
error instanceof Error ? error.constructor.name : 'UnknownError',
|
||||||
|
`tool_execution`,
|
||||||
|
name
|
||||||
|
);
|
||||||
|
|
||||||
// Provide more helpful error messages for common n8n issues
|
// Provide more helpful error messages for common n8n issues
|
||||||
let helpfulMessage = `Error executing tool ${name}: ${errorMessage}`;
|
let helpfulMessage = `Error executing tool ${name}: ${errorMessage}`;
|
||||||
|
|
||||||
|
|||||||
207
src/telemetry/config-manager.ts
Normal file
207
src/telemetry/config-manager.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Telemetry Configuration Manager
|
||||||
|
* Handles telemetry settings, opt-in/opt-out, and first-run detection
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { hostname, platform, arch } from 'os';
|
||||||
|
|
||||||
|
export interface TelemetryConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
userId: string;
|
||||||
|
firstRun?: string;
|
||||||
|
lastModified?: string;
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TelemetryConfigManager {
|
||||||
|
private static instance: TelemetryConfigManager;
|
||||||
|
private readonly configDir: string;
|
||||||
|
private readonly configPath: string;
|
||||||
|
private config: TelemetryConfig | null = null;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.configDir = join(homedir(), '.n8n-mcp');
|
||||||
|
this.configPath = join(this.configDir, 'telemetry.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): TelemetryConfigManager {
|
||||||
|
if (!TelemetryConfigManager.instance) {
|
||||||
|
TelemetryConfigManager.instance = new TelemetryConfigManager();
|
||||||
|
}
|
||||||
|
return TelemetryConfigManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a deterministic anonymous user ID based on machine characteristics
|
||||||
|
*/
|
||||||
|
private generateUserId(): string {
|
||||||
|
const machineId = `${hostname()}-${platform()}-${arch()}-${homedir()}`;
|
||||||
|
return createHash('sha256').update(machineId).digest('hex').substring(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load configuration from disk or create default
|
||||||
|
*/
|
||||||
|
loadConfig(): TelemetryConfig {
|
||||||
|
if (this.config) {
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(this.configPath)) {
|
||||||
|
// First run - create default config
|
||||||
|
this.config = {
|
||||||
|
enabled: true,
|
||||||
|
userId: this.generateUserId(),
|
||||||
|
firstRun: new Date().toISOString(),
|
||||||
|
version: require('../../package.json').version
|
||||||
|
};
|
||||||
|
|
||||||
|
this.saveConfig();
|
||||||
|
this.showFirstRunNotice();
|
||||||
|
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawConfig = readFileSync(this.configPath, 'utf-8');
|
||||||
|
this.config = JSON.parse(rawConfig);
|
||||||
|
|
||||||
|
// Ensure userId exists (for upgrades from older versions)
|
||||||
|
if (!this.config!.userId) {
|
||||||
|
this.config!.userId = this.generateUserId();
|
||||||
|
this.saveConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.config!;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load telemetry config, using defaults:', error);
|
||||||
|
this.config = {
|
||||||
|
enabled: false,
|
||||||
|
userId: this.generateUserId()
|
||||||
|
};
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save configuration to disk
|
||||||
|
*/
|
||||||
|
private saveConfig(): void {
|
||||||
|
if (!this.config) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!existsSync(this.configDir)) {
|
||||||
|
mkdirSync(this.configDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config.lastModified = new Date().toISOString();
|
||||||
|
writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save telemetry config:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if telemetry is enabled
|
||||||
|
*/
|
||||||
|
isEnabled(): boolean {
|
||||||
|
const config = this.loadConfig();
|
||||||
|
return config.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the anonymous user ID
|
||||||
|
*/
|
||||||
|
getUserId(): string {
|
||||||
|
const config = this.loadConfig();
|
||||||
|
return config.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is the first run
|
||||||
|
*/
|
||||||
|
isFirstRun(): boolean {
|
||||||
|
return !existsSync(this.configPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable telemetry
|
||||||
|
*/
|
||||||
|
enable(): void {
|
||||||
|
const config = this.loadConfig();
|
||||||
|
config.enabled = true;
|
||||||
|
this.config = config;
|
||||||
|
this.saveConfig();
|
||||||
|
console.log('✓ Anonymous telemetry enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable telemetry
|
||||||
|
*/
|
||||||
|
disable(): void {
|
||||||
|
const config = this.loadConfig();
|
||||||
|
config.enabled = false;
|
||||||
|
this.config = config;
|
||||||
|
this.saveConfig();
|
||||||
|
console.log('✓ Anonymous telemetry disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current status
|
||||||
|
*/
|
||||||
|
getStatus(): string {
|
||||||
|
const config = this.loadConfig();
|
||||||
|
return `
|
||||||
|
Telemetry Status: ${config.enabled ? 'ENABLED' : 'DISABLED'}
|
||||||
|
Anonymous ID: ${config.userId}
|
||||||
|
First Run: ${config.firstRun || 'Unknown'}
|
||||||
|
Config Path: ${this.configPath}
|
||||||
|
|
||||||
|
To opt-out: npx n8n-mcp telemetry disable
|
||||||
|
To opt-in: npx n8n-mcp telemetry enable
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show first-run notice to user
|
||||||
|
*/
|
||||||
|
private showFirstRunNotice(): void {
|
||||||
|
console.log(`
|
||||||
|
╔════════════════════════════════════════════════════════════╗
|
||||||
|
║ Anonymous Usage Statistics ║
|
||||||
|
╠════════════════════════════════════════════════════════════╣
|
||||||
|
║ ║
|
||||||
|
║ n8n-mcp collects anonymous usage data to improve the ║
|
||||||
|
║ tool and understand how it's being used. ║
|
||||||
|
║ ║
|
||||||
|
║ We track: ║
|
||||||
|
║ • Which MCP tools are used (no parameters) ║
|
||||||
|
║ • Workflow structures (sanitized, no sensitive data) ║
|
||||||
|
║ • Error patterns (hashed, no details) ║
|
||||||
|
║ • Performance metrics (timing, success rates) ║
|
||||||
|
║ ║
|
||||||
|
║ We NEVER collect: ║
|
||||||
|
║ • URLs, API keys, or credentials ║
|
||||||
|
║ • Workflow content or actual data ║
|
||||||
|
║ • Personal or identifiable information ║
|
||||||
|
║ • n8n instance details or locations ║
|
||||||
|
║ ║
|
||||||
|
║ Your anonymous ID: ${this.config?.userId || 'generating...'} ║
|
||||||
|
║ ║
|
||||||
|
║ This helps me understand usage patterns and improve ║
|
||||||
|
║ n8n-mcp for everyone. Thank you for your support! ║
|
||||||
|
║ ║
|
||||||
|
║ To opt-out at any time: ║
|
||||||
|
║ npx n8n-mcp telemetry disable ║
|
||||||
|
║ ║
|
||||||
|
║ Learn more: ║
|
||||||
|
║ https://github.com/czlonkowski/n8n-mcp/privacy ║
|
||||||
|
║ ║
|
||||||
|
╚════════════════════════════════════════════════════════════╝
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/telemetry/index.ts
Normal file
9
src/telemetry/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Telemetry Module
|
||||||
|
* Exports for anonymous usage statistics
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { TelemetryManager, telemetry } from './telemetry-manager';
|
||||||
|
export { TelemetryConfigManager } from './config-manager';
|
||||||
|
export { WorkflowSanitizer } from './workflow-sanitizer';
|
||||||
|
export type { TelemetryConfig } from './config-manager';
|
||||||
387
src/telemetry/telemetry-manager.ts
Normal file
387
src/telemetry/telemetry-manager.ts
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
/**
|
||||||
|
* Telemetry Manager
|
||||||
|
* Main telemetry class for anonymous usage statistics
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
import { TelemetryConfigManager } from './config-manager';
|
||||||
|
import { WorkflowSanitizer } from './workflow-sanitizer';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
interface TelemetryEvent {
|
||||||
|
user_id: string;
|
||||||
|
event: string;
|
||||||
|
properties: Record<string, any>;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkflowTelemetry {
|
||||||
|
user_id: string;
|
||||||
|
workflow_hash: string;
|
||||||
|
node_count: number;
|
||||||
|
node_types: string[];
|
||||||
|
has_trigger: boolean;
|
||||||
|
has_webhook: boolean;
|
||||||
|
complexity: 'simple' | 'medium' | 'complex';
|
||||||
|
sanitized_workflow: any;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TelemetryManager {
|
||||||
|
private static instance: TelemetryManager;
|
||||||
|
private supabase: SupabaseClient | null = null;
|
||||||
|
private configManager: TelemetryConfigManager;
|
||||||
|
private eventQueue: TelemetryEvent[] = [];
|
||||||
|
private workflowQueue: WorkflowTelemetry[] = [];
|
||||||
|
private flushTimer?: NodeJS.Timeout;
|
||||||
|
private isInitialized: boolean = false;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.configManager = TelemetryConfigManager.getInstance();
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): TelemetryManager {
|
||||||
|
if (!TelemetryManager.instance) {
|
||||||
|
TelemetryManager.instance = new TelemetryManager();
|
||||||
|
}
|
||||||
|
return TelemetryManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize telemetry if enabled
|
||||||
|
*/
|
||||||
|
private initialize(): void {
|
||||||
|
if (!this.configManager.isEnabled()) {
|
||||||
|
logger.debug('Telemetry disabled by user preference');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.SUPABASE_URL;
|
||||||
|
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
if (!supabaseUrl || !supabaseAnonKey) {
|
||||||
|
logger.debug('Telemetry not configured: missing SUPABASE_URL or SUPABASE_ANON_KEY');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||||
|
auth: {
|
||||||
|
persistSession: false,
|
||||||
|
autoRefreshToken: false,
|
||||||
|
},
|
||||||
|
realtime: {
|
||||||
|
params: {
|
||||||
|
eventsPerSecond: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.isInitialized = true;
|
||||||
|
this.startBatchProcessor();
|
||||||
|
|
||||||
|
// Flush on exit
|
||||||
|
process.on('beforeExit', () => this.flush());
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
this.flush();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
this.flush();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('Telemetry initialized successfully');
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('Failed to initialize telemetry:', error);
|
||||||
|
this.isInitialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track a tool usage event
|
||||||
|
*/
|
||||||
|
trackToolUsage(toolName: string, success: boolean, duration?: number): void {
|
||||||
|
if (!this.isEnabled()) return;
|
||||||
|
|
||||||
|
// Sanitize tool name (remove any potential sensitive data)
|
||||||
|
const sanitizedToolName = toolName.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
|
||||||
|
this.trackEvent('tool_used', {
|
||||||
|
tool: sanitizedToolName,
|
||||||
|
success,
|
||||||
|
duration: duration || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track workflow creation
|
||||||
|
*/
|
||||||
|
async trackWorkflowCreation(workflow: any, validationPassed: boolean): Promise<void> {
|
||||||
|
if (!this.isEnabled()) return;
|
||||||
|
|
||||||
|
// Only store workflows that pass validation
|
||||||
|
if (!validationPassed) {
|
||||||
|
this.trackEvent('workflow_validation_failed', {
|
||||||
|
nodeCount: workflow.nodes?.length || 0,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow);
|
||||||
|
|
||||||
|
const telemetryData: WorkflowTelemetry = {
|
||||||
|
user_id: this.configManager.getUserId(),
|
||||||
|
workflow_hash: sanitized.workflowHash,
|
||||||
|
node_count: sanitized.nodeCount,
|
||||||
|
node_types: sanitized.nodeTypes,
|
||||||
|
has_trigger: sanitized.hasTrigger,
|
||||||
|
has_webhook: sanitized.hasWebhook,
|
||||||
|
complexity: sanitized.complexity,
|
||||||
|
sanitized_workflow: {
|
||||||
|
nodes: sanitized.nodes,
|
||||||
|
connections: sanitized.connections,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.workflowQueue.push(telemetryData);
|
||||||
|
|
||||||
|
// Also track as event
|
||||||
|
this.trackEvent('workflow_created', {
|
||||||
|
nodeCount: sanitized.nodeCount,
|
||||||
|
nodeTypes: sanitized.nodeTypes.length,
|
||||||
|
complexity: sanitized.complexity,
|
||||||
|
hasTrigger: sanitized.hasTrigger,
|
||||||
|
hasWebhook: sanitized.hasWebhook,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flush if queue is getting large
|
||||||
|
if (this.workflowQueue.length >= 5) {
|
||||||
|
await this.flush();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('Failed to track workflow creation:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track an error event
|
||||||
|
*/
|
||||||
|
trackError(errorType: string, context: string, toolName?: string): void {
|
||||||
|
if (!this.isEnabled()) return;
|
||||||
|
|
||||||
|
this.trackEvent('error_occurred', {
|
||||||
|
errorType: this.sanitizeErrorType(errorType),
|
||||||
|
context: this.sanitizeContext(context),
|
||||||
|
tool: toolName ? toolName.replace(/[^a-zA-Z0-9_-]/g, '_') : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track a generic event
|
||||||
|
*/
|
||||||
|
trackEvent(eventName: string, properties: Record<string, any>): void {
|
||||||
|
if (!this.isEnabled()) return;
|
||||||
|
|
||||||
|
const event: TelemetryEvent = {
|
||||||
|
user_id: this.configManager.getUserId(),
|
||||||
|
event: eventName,
|
||||||
|
properties: this.sanitizeProperties(properties),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.eventQueue.push(event);
|
||||||
|
|
||||||
|
// Flush if queue is getting large
|
||||||
|
if (this.eventQueue.length >= 20) {
|
||||||
|
this.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track session start
|
||||||
|
*/
|
||||||
|
trackSessionStart(): void {
|
||||||
|
if (!this.isEnabled()) return;
|
||||||
|
|
||||||
|
this.trackEvent('session_start', {
|
||||||
|
version: require('../../package.json').version,
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
nodeVersion: process.version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush queued events to Supabase
|
||||||
|
*/
|
||||||
|
async flush(): Promise<void> {
|
||||||
|
if (!this.isEnabled() || !this.supabase) return;
|
||||||
|
|
||||||
|
// Flush events
|
||||||
|
if (this.eventQueue.length > 0) {
|
||||||
|
const events = [...this.eventQueue];
|
||||||
|
this.eventQueue = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await this.supabase
|
||||||
|
.from('telemetry_events')
|
||||||
|
.insert(events);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.debug('Failed to flush telemetry events:', error.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('Error flushing telemetry events:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush workflows
|
||||||
|
if (this.workflowQueue.length > 0) {
|
||||||
|
const workflows = [...this.workflowQueue];
|
||||||
|
this.workflowQueue = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use upsert to avoid duplicates based on workflow_hash
|
||||||
|
const { error } = await this.supabase
|
||||||
|
.from('telemetry_workflows')
|
||||||
|
.upsert(workflows, {
|
||||||
|
onConflict: 'workflow_hash',
|
||||||
|
ignoreDuplicates: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.debug('Failed to flush telemetry workflows:', error.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('Error flushing telemetry workflows:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start batch processor for periodic flushing
|
||||||
|
*/
|
||||||
|
private startBatchProcessor(): void {
|
||||||
|
// Flush every 30 seconds
|
||||||
|
this.flushTimer = setInterval(() => {
|
||||||
|
this.flush();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
// Prevent timer from keeping process alive
|
||||||
|
this.flushTimer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if telemetry is enabled
|
||||||
|
*/
|
||||||
|
private isEnabled(): boolean {
|
||||||
|
return this.isInitialized && this.configManager.isEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize properties to remove sensitive data
|
||||||
|
*/
|
||||||
|
private sanitizeProperties(properties: Record<string, any>): Record<string, any> {
|
||||||
|
const sanitized: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(properties)) {
|
||||||
|
// Skip sensitive keys
|
||||||
|
if (this.isSensitiveKey(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize values
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
sanitized[key] = this.sanitizeString(value);
|
||||||
|
} else if (typeof value === 'object' && value !== null) {
|
||||||
|
sanitized[key] = this.sanitizeProperties(value);
|
||||||
|
} else {
|
||||||
|
sanitized[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a key is sensitive
|
||||||
|
*/
|
||||||
|
private isSensitiveKey(key: string): boolean {
|
||||||
|
const sensitiveKeys = [
|
||||||
|
'password', 'token', 'key', 'secret', 'credential',
|
||||||
|
'auth', 'url', 'endpoint', 'host', 'database',
|
||||||
|
];
|
||||||
|
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
return sensitiveKeys.some(sensitive => lowerKey.includes(sensitive));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize string values
|
||||||
|
*/
|
||||||
|
private sanitizeString(value: string): string {
|
||||||
|
// Remove URLs
|
||||||
|
let sanitized = value.replace(/https?:\/\/[^\s]+/gi, '[URL]');
|
||||||
|
|
||||||
|
// Remove potential API keys (long alphanumeric strings)
|
||||||
|
sanitized = sanitized.replace(/[a-zA-Z0-9_-]{32,}/g, '[KEY]');
|
||||||
|
|
||||||
|
// Remove email addresses
|
||||||
|
sanitized = sanitized.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '[EMAIL]');
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize error type
|
||||||
|
*/
|
||||||
|
private sanitizeErrorType(errorType: string): string {
|
||||||
|
// Remove any potential sensitive data from error type
|
||||||
|
return errorType
|
||||||
|
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||||
|
.substring(0, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize context
|
||||||
|
*/
|
||||||
|
private sanitizeContext(context: string): string {
|
||||||
|
// Remove any potential sensitive data from context
|
||||||
|
return context
|
||||||
|
.replace(/https?:\/\/[^\s]+/gi, '[URL]')
|
||||||
|
.replace(/[a-zA-Z0-9_-]{32,}/g, '[KEY]')
|
||||||
|
.substring(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable telemetry
|
||||||
|
*/
|
||||||
|
disable(): void {
|
||||||
|
this.configManager.disable();
|
||||||
|
if (this.flushTimer) {
|
||||||
|
clearInterval(this.flushTimer);
|
||||||
|
}
|
||||||
|
this.isInitialized = false;
|
||||||
|
this.supabase = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable telemetry
|
||||||
|
*/
|
||||||
|
enable(): void {
|
||||||
|
this.configManager.enable();
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get telemetry status
|
||||||
|
*/
|
||||||
|
getStatus(): string {
|
||||||
|
return this.configManager.getStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const telemetry = TelemetryManager.getInstance();
|
||||||
299
src/telemetry/workflow-sanitizer.ts
Normal file
299
src/telemetry/workflow-sanitizer.ts
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
/**
|
||||||
|
* Workflow Sanitizer
|
||||||
|
* Removes sensitive data from workflows before telemetry storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
interface WorkflowNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
position: [number, number];
|
||||||
|
parameters: any;
|
||||||
|
credentials?: any;
|
||||||
|
disabled?: boolean;
|
||||||
|
typeVersion?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SanitizedWorkflow {
|
||||||
|
nodes: WorkflowNode[];
|
||||||
|
connections: any;
|
||||||
|
nodeCount: number;
|
||||||
|
nodeTypes: string[];
|
||||||
|
hasTrigger: boolean;
|
||||||
|
hasWebhook: boolean;
|
||||||
|
complexity: 'simple' | 'medium' | 'complex';
|
||||||
|
workflowHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkflowSanitizer {
|
||||||
|
private static readonly SENSITIVE_PATTERNS = [
|
||||||
|
// Webhook URLs (replace with placeholder but keep structure) - MUST BE FIRST
|
||||||
|
/https?:\/\/[^\s/]+\/webhook\/[^\s]+/g,
|
||||||
|
/https?:\/\/[^\s/]+\/hook\/[^\s]+/g,
|
||||||
|
|
||||||
|
// API keys and tokens
|
||||||
|
/sk-[a-zA-Z0-9]{16,}/g, // OpenAI keys
|
||||||
|
/Bearer\s+[^\s]+/gi, // Bearer tokens
|
||||||
|
/[a-zA-Z0-9_-]{20,}/g, // Long alphanumeric strings (API keys) - reduced threshold
|
||||||
|
/token['":\s]+[^,}]+/gi, // Token fields
|
||||||
|
/apikey['":\s]+[^,}]+/gi, // API key fields
|
||||||
|
/api_key['":\s]+[^,}]+/gi,
|
||||||
|
/secret['":\s]+[^,}]+/gi,
|
||||||
|
/password['":\s]+[^,}]+/gi,
|
||||||
|
/credential['":\s]+[^,}]+/gi,
|
||||||
|
|
||||||
|
// URLs with authentication
|
||||||
|
/https?:\/\/[^:]+:[^@]+@[^\s/]+/g, // URLs with auth
|
||||||
|
/wss?:\/\/[^:]+:[^@]+@[^\s/]+/g,
|
||||||
|
|
||||||
|
// Email addresses (optional - uncomment if needed)
|
||||||
|
// /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly SENSITIVE_FIELDS = [
|
||||||
|
'apiKey',
|
||||||
|
'api_key',
|
||||||
|
'token',
|
||||||
|
'secret',
|
||||||
|
'password',
|
||||||
|
'credential',
|
||||||
|
'auth',
|
||||||
|
'authorization',
|
||||||
|
'webhook',
|
||||||
|
'webhookUrl',
|
||||||
|
'url',
|
||||||
|
'endpoint',
|
||||||
|
'host',
|
||||||
|
'server',
|
||||||
|
'database',
|
||||||
|
'connectionString',
|
||||||
|
'privateKey',
|
||||||
|
'publicKey',
|
||||||
|
'certificate',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a complete workflow
|
||||||
|
*/
|
||||||
|
static sanitizeWorkflow(workflow: any): SanitizedWorkflow {
|
||||||
|
// Create a deep copy to avoid modifying original
|
||||||
|
const sanitized = JSON.parse(JSON.stringify(workflow));
|
||||||
|
|
||||||
|
// Sanitize nodes
|
||||||
|
if (sanitized.nodes && Array.isArray(sanitized.nodes)) {
|
||||||
|
sanitized.nodes = sanitized.nodes.map((node: WorkflowNode) =>
|
||||||
|
this.sanitizeNode(node)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize connections (keep structure only)
|
||||||
|
if (sanitized.connections) {
|
||||||
|
sanitized.connections = this.sanitizeConnections(sanitized.connections);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove other potentially sensitive data
|
||||||
|
delete sanitized.settings?.errorWorkflow;
|
||||||
|
delete sanitized.staticData;
|
||||||
|
delete sanitized.pinData;
|
||||||
|
delete sanitized.credentials;
|
||||||
|
delete sanitized.sharedWorkflows;
|
||||||
|
delete sanitized.ownedBy;
|
||||||
|
delete sanitized.createdBy;
|
||||||
|
delete sanitized.updatedBy;
|
||||||
|
|
||||||
|
// Calculate metrics
|
||||||
|
const nodeTypes = sanitized.nodes?.map((n: WorkflowNode) => n.type) || [];
|
||||||
|
const uniqueNodeTypes = [...new Set(nodeTypes)] as string[];
|
||||||
|
|
||||||
|
const hasTrigger = nodeTypes.some((type: string) =>
|
||||||
|
type.includes('trigger') || type.includes('webhook')
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasWebhook = nodeTypes.some((type: string) =>
|
||||||
|
type.includes('webhook')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate complexity
|
||||||
|
const nodeCount = sanitized.nodes?.length || 0;
|
||||||
|
let complexity: 'simple' | 'medium' | 'complex' = 'simple';
|
||||||
|
if (nodeCount > 20) {
|
||||||
|
complexity = 'complex';
|
||||||
|
} else if (nodeCount > 10) {
|
||||||
|
complexity = 'medium';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate workflow hash (for deduplication)
|
||||||
|
const workflowStructure = JSON.stringify({
|
||||||
|
nodeTypes: uniqueNodeTypes.sort(),
|
||||||
|
connections: sanitized.connections
|
||||||
|
});
|
||||||
|
const workflowHash = createHash('sha256')
|
||||||
|
.update(workflowStructure)
|
||||||
|
.digest('hex')
|
||||||
|
.substring(0, 16);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: sanitized.nodes || [],
|
||||||
|
connections: sanitized.connections || {},
|
||||||
|
nodeCount,
|
||||||
|
nodeTypes: uniqueNodeTypes,
|
||||||
|
hasTrigger,
|
||||||
|
hasWebhook,
|
||||||
|
complexity,
|
||||||
|
workflowHash
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a single node
|
||||||
|
*/
|
||||||
|
private static sanitizeNode(node: WorkflowNode): WorkflowNode {
|
||||||
|
const sanitized = { ...node };
|
||||||
|
|
||||||
|
// Remove credentials entirely
|
||||||
|
delete sanitized.credentials;
|
||||||
|
|
||||||
|
// Sanitize parameters
|
||||||
|
if (sanitized.parameters) {
|
||||||
|
sanitized.parameters = this.sanitizeObject(sanitized.parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively sanitize an object
|
||||||
|
*/
|
||||||
|
private static sanitizeObject(obj: any): any {
|
||||||
|
if (!obj || typeof obj !== 'object') {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map(item => this.sanitizeObject(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized: any = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
// Check if key is sensitive
|
||||||
|
if (this.isSensitiveField(key)) {
|
||||||
|
sanitized[key] = '[REDACTED]';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively sanitize nested objects
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
sanitized[key] = this.sanitizeObject(value);
|
||||||
|
}
|
||||||
|
// Sanitize string values
|
||||||
|
else if (typeof value === 'string') {
|
||||||
|
sanitized[key] = this.sanitizeString(value, key);
|
||||||
|
}
|
||||||
|
// Keep other types as-is
|
||||||
|
else {
|
||||||
|
sanitized[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize string values
|
||||||
|
*/
|
||||||
|
private static sanitizeString(value: string, fieldName: string): string {
|
||||||
|
// First check if this is a webhook URL
|
||||||
|
if (value.includes('/webhook/') || value.includes('/hook/')) {
|
||||||
|
return 'https://[webhook-url]';
|
||||||
|
}
|
||||||
|
|
||||||
|
let sanitized = value;
|
||||||
|
|
||||||
|
// Apply all sensitive patterns
|
||||||
|
for (const pattern of this.SENSITIVE_PATTERNS) {
|
||||||
|
// Skip webhook patterns - already handled above
|
||||||
|
if (pattern.toString().includes('webhook')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sanitized = sanitized.replace(pattern, '[REDACTED]');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional sanitization for specific field types
|
||||||
|
if (fieldName.toLowerCase().includes('url') ||
|
||||||
|
fieldName.toLowerCase().includes('endpoint')) {
|
||||||
|
// Keep URL structure but remove domain details
|
||||||
|
if (sanitized.startsWith('http://') || sanitized.startsWith('https://')) {
|
||||||
|
// If value has been redacted, leave it as is
|
||||||
|
if (sanitized.includes('[REDACTED]')) {
|
||||||
|
return '[REDACTED]';
|
||||||
|
}
|
||||||
|
const urlParts = sanitized.split('/');
|
||||||
|
if (urlParts.length > 2) {
|
||||||
|
urlParts[2] = '[domain]';
|
||||||
|
sanitized = urlParts.join('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a field name is sensitive
|
||||||
|
*/
|
||||||
|
private static isSensitiveField(fieldName: string): boolean {
|
||||||
|
const lowerFieldName = fieldName.toLowerCase();
|
||||||
|
return this.SENSITIVE_FIELDS.some(sensitive =>
|
||||||
|
lowerFieldName.includes(sensitive.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize connections (keep structure only)
|
||||||
|
*/
|
||||||
|
private static sanitizeConnections(connections: any): any {
|
||||||
|
if (!connections || typeof connections !== 'object') {
|
||||||
|
return connections;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized: any = {};
|
||||||
|
|
||||||
|
for (const [nodeId, nodeConnections] of Object.entries(connections)) {
|
||||||
|
if (typeof nodeConnections === 'object' && nodeConnections !== null) {
|
||||||
|
sanitized[nodeId] = {};
|
||||||
|
|
||||||
|
for (const [connType, connArray] of Object.entries(nodeConnections as any)) {
|
||||||
|
if (Array.isArray(connArray)) {
|
||||||
|
sanitized[nodeId][connType] = connArray.map((conns: any) => {
|
||||||
|
if (Array.isArray(conns)) {
|
||||||
|
return conns.map((conn: any) => ({
|
||||||
|
node: conn.node,
|
||||||
|
type: conn.type,
|
||||||
|
index: conn.index
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return conns;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
sanitized[nodeId][connType] = connArray;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sanitized[nodeId] = nodeConnections;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a hash for workflow deduplication
|
||||||
|
*/
|
||||||
|
static generateWorkflowHash(workflow: any): string {
|
||||||
|
const sanitized = this.sanitizeWorkflow(workflow);
|
||||||
|
return sanitized.workflowHash;
|
||||||
|
}
|
||||||
|
}
|
||||||
205
tests/unit/telemetry/config-manager.test.ts
Normal file
205
tests/unit/telemetry/config-manager.test.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { TelemetryConfigManager } from '../../../src/telemetry/config-manager';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
|
||||||
|
// Mock fs module
|
||||||
|
vi.mock('fs', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('fs')>('fs');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
existsSync: vi.fn(),
|
||||||
|
readFileSync: vi.fn(),
|
||||||
|
writeFileSync: vi.fn(),
|
||||||
|
mkdirSync: vi.fn()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TelemetryConfigManager', () => {
|
||||||
|
let manager: TelemetryConfigManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Clear singleton instance
|
||||||
|
(TelemetryConfigManager as any).instance = null;
|
||||||
|
|
||||||
|
// Mock console.log to suppress first-run notice in tests
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getInstance', () => {
|
||||||
|
it('should return singleton instance', () => {
|
||||||
|
const instance1 = TelemetryConfigManager.getInstance();
|
||||||
|
const instance2 = TelemetryConfigManager.getInstance();
|
||||||
|
expect(instance1).toBe(instance2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadConfig', () => {
|
||||||
|
it('should create default config on first run', () => {
|
||||||
|
vi.mocked(existsSync).mockReturnValue(false);
|
||||||
|
|
||||||
|
manager = TelemetryConfigManager.getInstance();
|
||||||
|
const config = manager.loadConfig();
|
||||||
|
|
||||||
|
expect(config.enabled).toBe(true);
|
||||||
|
expect(config.userId).toMatch(/^[a-f0-9]{16}$/);
|
||||||
|
expect(config.firstRun).toBeDefined();
|
||||||
|
expect(vi.mocked(mkdirSync)).toHaveBeenCalledWith(
|
||||||
|
join(homedir(), '.n8n-mcp'),
|
||||||
|
{ recursive: true }
|
||||||
|
);
|
||||||
|
expect(vi.mocked(writeFileSync)).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load existing config from disk', () => {
|
||||||
|
const mockConfig = {
|
||||||
|
enabled: false,
|
||||||
|
userId: 'test-user-id',
|
||||||
|
firstRun: '2024-01-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockConfig));
|
||||||
|
|
||||||
|
manager = TelemetryConfigManager.getInstance();
|
||||||
|
const config = manager.loadConfig();
|
||||||
|
|
||||||
|
expect(config).toEqual(mockConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle corrupted config file gracefully', () => {
|
||||||
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(readFileSync).mockReturnValue('invalid json');
|
||||||
|
|
||||||
|
manager = TelemetryConfigManager.getInstance();
|
||||||
|
const config = manager.loadConfig();
|
||||||
|
|
||||||
|
expect(config.enabled).toBe(false);
|
||||||
|
expect(config.userId).toMatch(/^[a-f0-9]{16}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add userId to config if missing', () => {
|
||||||
|
const mockConfig = {
|
||||||
|
enabled: true,
|
||||||
|
firstRun: '2024-01-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockConfig));
|
||||||
|
|
||||||
|
manager = TelemetryConfigManager.getInstance();
|
||||||
|
const config = manager.loadConfig();
|
||||||
|
|
||||||
|
expect(config.userId).toMatch(/^[a-f0-9]{16}$/);
|
||||||
|
expect(vi.mocked(writeFileSync)).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isEnabled', () => {
|
||||||
|
it('should return true when telemetry is enabled', () => {
|
||||||
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
userId: 'test-id'
|
||||||
|
}));
|
||||||
|
|
||||||
|
manager = TelemetryConfigManager.getInstance();
|
||||||
|
expect(manager.isEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when telemetry is disabled', () => {
|
||||||
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
||||||
|
enabled: false,
|
||||||
|
userId: 'test-id'
|
||||||
|
}));
|
||||||
|
|
||||||
|
manager = TelemetryConfigManager.getInstance();
|
||||||
|
expect(manager.isEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getUserId', () => {
|
||||||
|
it('should return consistent user ID', () => {
|
||||||
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
userId: 'test-user-id-123'
|
||||||
|
}));
|
||||||
|
|
||||||
|
manager = TelemetryConfigManager.getInstance();
|
||||||
|
expect(manager.getUserId()).toBe('test-user-id-123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isFirstRun', () => {
|
||||||
|
it('should return true if config file does not exist', () => {
|
||||||
|
vi.mocked(existsSync).mockReturnValue(false);
|
||||||
|
|
||||||
|
manager = TelemetryConfigManager.getInstance();
|
||||||
|
expect(manager.isFirstRun()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false if config file exists', () => {
|
||||||
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
|
|
||||||
|
manager = TelemetryConfigManager.getInstance();
|
||||||
|
expect(manager.isFirstRun()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enable/disable', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
||||||
|
enabled: false,
|
||||||
|
userId: 'test-id'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enable telemetry', () => {
|
||||||
|
manager = TelemetryConfigManager.getInstance();
|
||||||
|
manager.enable();
|
||||||
|
|
||||||
|
const calls = vi.mocked(writeFileSync).mock.calls;
|
||||||
|
expect(calls.length).toBeGreaterThan(0);
|
||||||
|
const lastCall = calls[calls.length - 1];
|
||||||
|
expect(lastCall[1]).toContain('"enabled": true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable telemetry', () => {
|
||||||
|
manager = TelemetryConfigManager.getInstance();
|
||||||
|
manager.disable();
|
||||||
|
|
||||||
|
const calls = vi.mocked(writeFileSync).mock.calls;
|
||||||
|
expect(calls.length).toBeGreaterThan(0);
|
||||||
|
const lastCall = calls[calls.length - 1];
|
||||||
|
expect(lastCall[1]).toContain('"enabled": false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStatus', () => {
|
||||||
|
it('should return formatted status string', () => {
|
||||||
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
userId: 'test-id',
|
||||||
|
firstRun: '2024-01-01T00:00:00Z'
|
||||||
|
}));
|
||||||
|
|
||||||
|
manager = TelemetryConfigManager.getInstance();
|
||||||
|
const status = manager.getStatus();
|
||||||
|
|
||||||
|
expect(status).toContain('ENABLED');
|
||||||
|
expect(status).toContain('test-id');
|
||||||
|
expect(status).toContain('2024-01-01T00:00:00Z');
|
||||||
|
expect(status).toContain('npx n8n-mcp telemetry');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
306
tests/unit/telemetry/workflow-sanitizer.test.ts
Normal file
306
tests/unit/telemetry/workflow-sanitizer.test.ts
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { WorkflowSanitizer } from '../../../src/telemetry/workflow-sanitizer';
|
||||||
|
|
||||||
|
describe('WorkflowSanitizer', () => {
|
||||||
|
describe('sanitizeWorkflow', () => {
|
||||||
|
it('should remove API keys from parameters', () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'HTTP Request',
|
||||||
|
type: 'n8n-nodes-base.httpRequest',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {
|
||||||
|
url: 'https://api.example.com',
|
||||||
|
apiKey: 'sk-1234567890abcdef1234567890abcdef',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer sk-1234567890abcdef1234567890abcdef'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow);
|
||||||
|
|
||||||
|
expect(sanitized.nodes[0].parameters.apiKey).toBe('[REDACTED]');
|
||||||
|
expect(sanitized.nodes[0].parameters.headers.Authorization).toBe('[REDACTED]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sanitize webhook URLs but keep structure', () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Webhook',
|
||||||
|
type: 'n8n-nodes-base.webhook',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {
|
||||||
|
path: 'my-webhook',
|
||||||
|
webhookUrl: 'https://n8n.example.com/webhook/abc-def-ghi',
|
||||||
|
method: 'POST'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow);
|
||||||
|
|
||||||
|
expect(sanitized.nodes[0].parameters.webhookUrl).toBe('https://[webhook-url]');
|
||||||
|
expect(sanitized.nodes[0].parameters.method).toBe('POST'); // Method should remain
|
||||||
|
expect(sanitized.nodes[0].parameters.path).toBe('my-webhook'); // Path should remain
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove credentials entirely', () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Slack',
|
||||||
|
type: 'n8n-nodes-base.slack',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {
|
||||||
|
channel: 'general',
|
||||||
|
text: 'Hello World'
|
||||||
|
},
|
||||||
|
credentials: {
|
||||||
|
slackApi: {
|
||||||
|
id: 'cred-123',
|
||||||
|
name: 'My Slack'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow);
|
||||||
|
|
||||||
|
expect(sanitized.nodes[0].credentials).toBeUndefined();
|
||||||
|
expect(sanitized.nodes[0].parameters.channel).toBe('general'); // Channel should remain
|
||||||
|
expect(sanitized.nodes[0].parameters.text).toBe('Hello World'); // Text should remain
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sanitize URLs in parameters', () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'HTTP Request',
|
||||||
|
type: 'n8n-nodes-base.httpRequest',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {
|
||||||
|
url: 'https://api.example.com/endpoint',
|
||||||
|
endpoint: 'https://another.example.com/api',
|
||||||
|
baseUrl: 'https://base.example.com'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow);
|
||||||
|
|
||||||
|
expect(sanitized.nodes[0].parameters.url).toBe('https://[domain]/endpoint');
|
||||||
|
expect(sanitized.nodes[0].parameters.endpoint).toBe('[REDACTED]');
|
||||||
|
expect(sanitized.nodes[0].parameters.baseUrl).toBe('https://[domain]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate workflow metrics correctly', () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Webhook',
|
||||||
|
type: 'n8n-nodes-base.webhook',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'HTTP Request',
|
||||||
|
type: 'n8n-nodes-base.httpRequest',
|
||||||
|
position: [200, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'Slack',
|
||||||
|
type: 'n8n-nodes-base.slack',
|
||||||
|
position: [300, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'1': {
|
||||||
|
main: [[{ node: '2', type: 'main', index: 0 }]]
|
||||||
|
},
|
||||||
|
'2': {
|
||||||
|
main: [[{ node: '3', type: 'main', index: 0 }]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow);
|
||||||
|
|
||||||
|
expect(sanitized.nodeCount).toBe(3);
|
||||||
|
expect(sanitized.nodeTypes).toContain('n8n-nodes-base.webhook');
|
||||||
|
expect(sanitized.nodeTypes).toContain('n8n-nodes-base.httpRequest');
|
||||||
|
expect(sanitized.nodeTypes).toContain('n8n-nodes-base.slack');
|
||||||
|
expect(sanitized.hasTrigger).toBe(true);
|
||||||
|
expect(sanitized.hasWebhook).toBe(true);
|
||||||
|
expect(sanitized.complexity).toBe('simple');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate complexity based on node count', () => {
|
||||||
|
const createWorkflow = (nodeCount: number) => ({
|
||||||
|
nodes: Array.from({ length: nodeCount }, (_, i) => ({
|
||||||
|
id: String(i),
|
||||||
|
name: `Node ${i}`,
|
||||||
|
type: 'n8n-nodes-base.function',
|
||||||
|
position: [i * 100, 100],
|
||||||
|
parameters: {}
|
||||||
|
})),
|
||||||
|
connections: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const simple = WorkflowSanitizer.sanitizeWorkflow(createWorkflow(5));
|
||||||
|
expect(simple.complexity).toBe('simple');
|
||||||
|
|
||||||
|
const medium = WorkflowSanitizer.sanitizeWorkflow(createWorkflow(15));
|
||||||
|
expect(medium.complexity).toBe('medium');
|
||||||
|
|
||||||
|
const complex = WorkflowSanitizer.sanitizeWorkflow(createWorkflow(25));
|
||||||
|
expect(complex.complexity).toBe('complex');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate consistent workflow hash', () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Webhook',
|
||||||
|
type: 'n8n-nodes-base.webhook',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: { path: 'test' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hash1 = WorkflowSanitizer.generateWorkflowHash(workflow);
|
||||||
|
const hash2 = WorkflowSanitizer.generateWorkflowHash(workflow);
|
||||||
|
|
||||||
|
expect(hash1).toBe(hash2);
|
||||||
|
expect(hash1).toMatch(/^[a-f0-9]{16}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sanitize nested objects in parameters', () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Complex Node',
|
||||||
|
type: 'n8n-nodes-base.httpRequest',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {
|
||||||
|
options: {
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': 'secret-key-1234567890abcdef',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
data: 'some data',
|
||||||
|
token: 'another-secret-token-xyz123'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow);
|
||||||
|
|
||||||
|
expect(sanitized.nodes[0].parameters.options.headers['X-API-Key']).toBe('[REDACTED]');
|
||||||
|
expect(sanitized.nodes[0].parameters.options.headers['Content-Type']).toBe('application/json');
|
||||||
|
expect(sanitized.nodes[0].parameters.options.body.data).toBe('some data');
|
||||||
|
expect(sanitized.nodes[0].parameters.options.body.token).toBe('[REDACTED]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve connections structure', () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Node 1',
|
||||||
|
type: 'n8n-nodes-base.start',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Node 2',
|
||||||
|
type: 'n8n-nodes-base.function',
|
||||||
|
position: [200, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'1': {
|
||||||
|
main: [[{ node: '2', type: 'main', index: 0 }]],
|
||||||
|
error: [[{ node: '2', type: 'error', index: 0 }]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow);
|
||||||
|
|
||||||
|
expect(sanitized.connections).toEqual({
|
||||||
|
'1': {
|
||||||
|
main: [[{ node: '2', type: 'main', index: 0 }]],
|
||||||
|
error: [[{ node: '2', type: 'error', index: 0 }]]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove sensitive workflow metadata', () => {
|
||||||
|
const workflow = {
|
||||||
|
id: 'workflow-123',
|
||||||
|
name: 'My Workflow',
|
||||||
|
nodes: [],
|
||||||
|
connections: {},
|
||||||
|
settings: {
|
||||||
|
errorWorkflow: 'error-workflow-id',
|
||||||
|
timezone: 'America/New_York'
|
||||||
|
},
|
||||||
|
staticData: { some: 'data' },
|
||||||
|
pinData: { node1: 'pinned' },
|
||||||
|
credentials: { slack: 'cred-123' },
|
||||||
|
sharedWorkflows: ['user-456'],
|
||||||
|
ownedBy: 'user-123',
|
||||||
|
createdBy: 'user-123',
|
||||||
|
updatedBy: 'user-456'
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitized = WorkflowSanitizer.sanitizeWorkflow(workflow);
|
||||||
|
|
||||||
|
// These should be removed
|
||||||
|
expect(sanitized.settings?.errorWorkflow).toBeUndefined();
|
||||||
|
expect(sanitized.staticData).toBeUndefined();
|
||||||
|
expect(sanitized.pinData).toBeUndefined();
|
||||||
|
expect(sanitized.credentials).toBeUndefined();
|
||||||
|
expect(sanitized.sharedWorkflows).toBeUndefined();
|
||||||
|
expect(sanitized.ownedBy).toBeUndefined();
|
||||||
|
expect(sanitized.createdBy).toBeUndefined();
|
||||||
|
expect(sanitized.updatedBy).toBeUndefined();
|
||||||
|
|
||||||
|
// These should be preserved
|
||||||
|
expect(sanitized.nodes).toEqual([]);
|
||||||
|
expect(sanitized.connections).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user