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:
czlonkowski
2025-09-25 13:33:16 +02:00
parent 78abda601a
commit 5960d2826e
12 changed files with 1569 additions and 30 deletions

View 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');
});
});
});

View 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({});
});
});
});