feat: implement anonymous telemetry system with Supabase integration

Adds zero-configuration anonymous usage statistics to track:
- Number of active users with deterministic user IDs
- Which MCP tools AI agents use most
- What workflows are built (sanitized to protect privacy)
- Common errors and issues

Key features:
- Zero-configuration design with hardcoded write-only credentials
- Privacy-first approach with comprehensive data sanitization
- Opt-out support via config file and environment variables
- Docker-friendly with environment variable support
- Multi-process safe with immediate flush strategy
- Row Level Security (RLS) policies for write-only access

Technical implementation:
- Supabase backend with anon key for INSERT-only operations
- Workflow sanitization removes all sensitive data
- Environment variables checked for opt-out (TELEMETRY_DISABLED, etc.)
- Telemetry enabled by default but respects user preferences
- Cleaned up all debug logging for production readiness

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
czlonkowski
2025-09-25 22:00:11 +02:00
parent f150802bed
commit 09e69df5a7
16 changed files with 1015 additions and 214 deletions

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env npx tsx
/**
* Direct telemetry test with hardcoded credentials
*/
import { createClient } from '@supabase/supabase-js';
const TELEMETRY_BACKEND = {
URL: 'https://ydyufsohxdfpopqbubwk.supabase.co',
ANON_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkeXVmc29oeGRmcG9wcWJ1YndrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Mzc2MzAxMDgsImV4cCI6MjA1MzIwNjEwOH0.LsUTx9OsNtnqg-jxXaJPc84aBHVDehHiMaFoF2Ir8s0'
};
async function testDirect() {
console.log('🧪 Direct Telemetry Test\n');
const supabase = createClient(TELEMETRY_BACKEND.URL, TELEMETRY_BACKEND.ANON_KEY, {
auth: {
persistSession: false,
autoRefreshToken: false,
}
});
const testEvent = {
user_id: 'direct-test-' + Date.now(),
event: 'direct_test',
properties: {
source: 'test-telemetry-direct.ts',
timestamp: new Date().toISOString()
}
};
console.log('Sending event:', testEvent);
const { data, error } = await supabase
.from('telemetry_events')
.insert([testEvent]);
if (error) {
console.error('❌ Failed:', error);
} else {
console.log('✅ Success! Event sent directly to Supabase');
console.log('Response:', data);
}
}
testDirect().catch(console.error);

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env npx tsx
/**
* Test telemetry environment variable override
*/
import { TelemetryConfigManager } from '../src/telemetry/config-manager';
import { telemetry } from '../src/telemetry/telemetry-manager';
async function testEnvOverride() {
console.log('🧪 Testing Telemetry Environment Variable Override\n');
const configManager = TelemetryConfigManager.getInstance();
// Test 1: Check current status without env var
console.log('Test 1: Without environment variable');
console.log('Is Enabled:', configManager.isEnabled());
console.log('Status:', configManager.getStatus());
// Test 2: Set environment variable and check again
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
console.log('Test 2: With N8N_MCP_TELEMETRY_DISABLED=true');
process.env.N8N_MCP_TELEMETRY_DISABLED = 'true';
// Force reload by creating new instance (for testing)
const newConfigManager = TelemetryConfigManager.getInstance();
console.log('Is Enabled:', newConfigManager.isEnabled());
console.log('Status:', newConfigManager.getStatus());
// Test 3: Try tracking with env disabled
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
console.log('Test 3: Attempting to track with telemetry disabled');
telemetry.trackToolUsage('test_tool', true, 100);
console.log('Tool usage tracking attempted (should be ignored)');
// Test 4: Alternative env vars
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
console.log('Test 4: Alternative environment variables');
delete process.env.N8N_MCP_TELEMETRY_DISABLED;
process.env.TELEMETRY_DISABLED = 'true';
console.log('With TELEMETRY_DISABLED=true:', newConfigManager.isEnabled());
delete process.env.TELEMETRY_DISABLED;
process.env.DISABLE_TELEMETRY = 'true';
console.log('With DISABLE_TELEMETRY=true:', newConfigManager.isEnabled());
// Test 5: Env var takes precedence over config
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
console.log('Test 5: Environment variable precedence');
// Enable via config
newConfigManager.enable();
console.log('After enabling via config:', newConfigManager.isEnabled());
// But env var should still override
process.env.N8N_MCP_TELEMETRY_DISABLED = 'true';
console.log('With env var set (should override config):', newConfigManager.isEnabled());
console.log('\n✅ All tests completed!');
}
testEnvOverride().catch(console.error);

View File

@@ -1,106 +1,73 @@
#!/usr/bin/env npx tsx
/**
* Test script for telemetry integration
* Verifies that telemetry data can be sent to Supabase
* Integration test for the telemetry manager
*/
import { telemetry } from '../src/telemetry';
import { WorkflowSanitizer } from '../src/telemetry/workflow-sanitizer';
import dotenv from 'dotenv';
import { telemetry } from '../src/telemetry/telemetry-manager';
// Load environment variables
dotenv.config();
async function testIntegration() {
console.log('🧪 Testing Telemetry Manager Integration\n');
async function testTelemetryIntegration() {
console.log('🧪 Testing Telemetry Integration with Supabase\n');
// Check status
console.log('Status:', telemetry.getStatus());
// Check environment variables
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_ANON_KEY;
// Track session start
console.log('\nTracking session start...');
telemetry.trackSessionStart();
if (!supabaseUrl || !supabaseKey) {
console.error('❌ Missing SUPABASE_URL or SUPABASE_ANON_KEY in .env file');
process.exit(1);
}
// Track tool usage
console.log('Tracking tool usage...');
telemetry.trackToolUsage('search_nodes', true, 150);
telemetry.trackToolUsage('get_node_info', true, 75);
telemetry.trackToolUsage('validate_workflow', false, 200);
console.log('✅ Environment variables configured');
console.log(` Supabase URL: ${supabaseUrl}`);
console.log(` Anon Key: ${supabaseKey.substring(0, 20)}...`);
// Test 1: Track tool usage
console.log('\n📊 Test 1: Tracking tool usage...');
telemetry.trackToolUsage('search_nodes', true, 1250);
telemetry.trackToolUsage('get_node_info', true, 850);
telemetry.trackToolUsage('validate_workflow', false, 2000);
console.log(' ✓ Tool usage events queued');
// Test 2: Track errors
console.log('\n🐛 Test 2: Tracking errors...');
// Track errors
console.log('Tracking errors...');
telemetry.trackError('ValidationError', 'workflow_validation', 'validate_workflow');
telemetry.trackError('NetworkError', 'api_call', 'n8n_create_workflow');
console.log(' ✓ Error events queued');
// Test 3: Track workflow creation
console.log('\n🔧 Test 3: Tracking workflow creation...');
// Track a test workflow
console.log('Tracking workflow creation...');
const testWorkflow = {
name: 'Test Workflow',
nodes: [
{
id: '1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [100, 100],
name: 'Webhook',
position: [0, 0],
parameters: {
path: 'test-webhook',
webhookUrl: 'https://n8n.example.com/webhook/abc-123-def',
method: 'POST',
authentication: 'none'
},
credentials: {
webhookAuth: {
id: 'cred-123',
name: 'My Webhook Auth'
}
path: '/test-webhook',
httpMethod: 'POST'
}
},
{
id: '2',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
position: [300, 100],
name: 'HTTP Request',
position: [250, 0],
parameters: {
url: 'https://api.example.com/endpoint',
method: 'POST',
authentication: 'genericCredentialType',
genericAuthType: 'httpHeaderAuth',
httpHeaders: {
sendHeaders: true,
headerParameters: {
parameters: [
{
name: 'Authorization',
value: 'Bearer sk-1234567890abcdef1234567890abcdef'
value: 'Bearer sk-1234567890abcdef'
}
]
},
options: {
timeout: 10000
}
}
},
{
id: '3',
name: 'Slack',
type: 'n8n-nodes-base.slack',
position: [500, 100],
name: 'Slack',
position: [500, 0],
parameters: {
channel: 'general',
text: 'Message sent!',
authentication: 'accessToken'
},
credentials: {
slackApi: {
id: 'cred-456',
name: 'My Slack'
}
channel: '#notifications',
text: 'Workflow completed!'
}
}
],
@@ -111,72 +78,17 @@ async function testTelemetryIntegration() {
'2': {
main: [[{ node: '3', type: 'main', index: 0 }]]
}
},
settings: {
errorWorkflow: 'error-workflow-id',
saveDataErrorExecution: 'all',
saveDataSuccessExecution: 'none',
saveExecutionProgress: true,
saveManualExecutions: true,
timezone: 'America/New_York'
},
staticData: { some: 'data' },
pinData: { node1: 'pinned' },
ownedBy: 'user-123',
createdBy: 'user-123',
updatedBy: 'user-456'
}
};
// Track successful workflow
await telemetry.trackWorkflowCreation(testWorkflow, true);
console.log(' ✓ Workflow creation tracked');
telemetry.trackWorkflowCreation(testWorkflow, true);
// Test workflow sanitization
console.log('\n🔒 Test 4: Verifying workflow sanitization...');
const sanitized = WorkflowSanitizer.sanitizeWorkflow(testWorkflow);
// Verify sensitive data was removed
const sanitizedStr = JSON.stringify(sanitized);
const hasSensitiveData =
sanitizedStr.includes('sk-1234567890abcdef') ||
sanitizedStr.includes('cred-123') ||
sanitizedStr.includes('cred-456') ||
sanitizedStr.includes('user-123');
if (hasSensitiveData) {
console.error(' ❌ Sensitive data found in sanitized workflow!');
} else {
console.log(' ✓ All sensitive data removed');
}
console.log(' ✓ Workflow hash:', sanitized.workflowHash);
console.log(' ✓ Node count:', sanitized.nodeCount);
console.log(' ✓ Node types:', sanitized.nodeTypes);
console.log(' ✓ Complexity:', sanitized.complexity);
// Test 5: Track session start
console.log('\n🚀 Test 5: Tracking session start...');
telemetry.trackSessionStart();
console.log(' ✓ Session start tracked');
// Flush all events
console.log('\n💾 Flushing telemetry data to Supabase...');
// Force flush
console.log('\nFlushing telemetry data...');
await telemetry.flush();
console.log(' ✓ Data flushed to Supabase');
// Test 6: Verify data in Supabase
console.log('\n🔍 Test 6: Verifying data in Supabase...');
console.log(' Please check your Supabase dashboard to verify:');
console.log(' - telemetry_events table has new records');
console.log(' - telemetry_workflows table has the test workflow');
console.log(' - Views show aggregated data');
console.log('\n Dashboard URL: https://supabase.com/dashboard/project/ydyufsohxdfpopqbubwk/editor');
console.log('\n✨ Telemetry integration test completed!');
console.log('\n✅ Telemetry integration test completed!');
console.log('Check your Supabase dashboard for the telemetry data.');
}
// Run the test
testTelemetryIntegration().catch(error => {
console.error('❌ Test failed:', error);
process.exit(1);
});
testIntegration().catch(console.error);

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env npx tsx
/**
* Test that RLS properly protects data
*/
import { createClient } from '@supabase/supabase-js';
import dotenv from 'dotenv';
dotenv.config();
async function testSecurity() {
const supabaseUrl = process.env.SUPABASE_URL!;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY!;
console.log('🔒 Testing Telemetry Security (RLS)\n');
const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: false,
autoRefreshToken: false,
}
});
// Test 1: Verify anon can INSERT
console.log('Test 1: Anonymous INSERT (should succeed)...');
const testData = {
user_id: 'security-test-' + Date.now(),
event: 'security_test',
properties: { test: true }
};
const { error: insertError } = await supabase
.from('telemetry_events')
.insert([testData]);
if (insertError) {
console.error('❌ Insert failed:', insertError.message);
} else {
console.log('✅ Insert succeeded (as expected)');
}
// Test 2: Verify anon CANNOT SELECT
console.log('\nTest 2: Anonymous SELECT (should fail)...');
const { data, error: selectError } = await supabase
.from('telemetry_events')
.select('*')
.limit(1);
if (selectError) {
console.log('✅ Select blocked by RLS (as expected):', selectError.message);
} else if (data && data.length > 0) {
console.error('❌ SECURITY ISSUE: Anon can read data!', data);
} else if (data && data.length === 0) {
console.log('⚠️ Select returned empty array (might be RLS working)');
}
// Test 3: Verify anon CANNOT UPDATE
console.log('\nTest 3: Anonymous UPDATE (should fail)...');
const { error: updateError } = await supabase
.from('telemetry_events')
.update({ event: 'hacked' })
.eq('user_id', 'test');
if (updateError) {
console.log('✅ Update blocked (as expected):', updateError.message);
} else {
console.error('❌ SECURITY ISSUE: Anon can update data!');
}
// Test 4: Verify anon CANNOT DELETE
console.log('\nTest 4: Anonymous DELETE (should fail)...');
const { error: deleteError } = await supabase
.from('telemetry_events')
.delete()
.eq('user_id', 'test');
if (deleteError) {
console.log('✅ Delete blocked (as expected):', deleteError.message);
} else {
console.error('❌ SECURITY ISSUE: Anon can delete data!');
}
console.log('\n✨ Security test completed!');
console.log('Summary: Anonymous users can INSERT (for telemetry) but cannot READ/UPDATE/DELETE');
}
testSecurity().catch(console.error);

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env npx tsx
/**
* Test direct workflow insert to Supabase
*/
import { createClient } from '@supabase/supabase-js';
const TELEMETRY_BACKEND = {
URL: 'https://ydyufsohxdfpopqbubwk.supabase.co',
ANON_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkeXVmc29oeGRmcG9wcWJ1YndrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTg3OTYyMDAsImV4cCI6MjA3NDM3MjIwMH0.xESphg6h5ozaDsm4Vla3QnDJGc6Nc_cpfoqTHRynkCk'
};
async function testWorkflowInsert() {
const supabase = createClient(TELEMETRY_BACKEND.URL, TELEMETRY_BACKEND.ANON_KEY, {
auth: {
persistSession: false,
autoRefreshToken: false,
}
});
const testWorkflow = {
user_id: 'direct-test-' + Date.now(),
workflow_hash: 'hash-direct-' + Date.now(),
node_count: 2,
node_types: ['webhook', 'http'],
has_trigger: true,
has_webhook: true,
complexity: 'simple' as const,
sanitized_workflow: {
nodes: [
{ id: '1', type: 'webhook', parameters: {} },
{ id: '2', type: 'http', parameters: {} }
],
connections: {}
}
};
console.log('Attempting direct insert to telemetry_workflows...');
console.log('Data:', JSON.stringify(testWorkflow, null, 2));
const { data, error } = await supabase
.from('telemetry_workflows')
.insert([testWorkflow]);
if (error) {
console.error('\n❌ Error:', error);
} else {
console.log('\n✅ Success! Workflow inserted');
if (data) {
console.log('Response:', data);
}
}
}
testWorkflowInsert().catch(console.error);

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env npx tsx
/**
* Test workflow sanitizer
*/
import { WorkflowSanitizer } from '../src/telemetry/workflow-sanitizer';
const testWorkflow = {
nodes: [
{
id: 'webhook1',
type: 'n8n-nodes-base.webhook',
name: 'Webhook',
position: [0, 0],
parameters: {
path: '/test-webhook',
httpMethod: 'POST'
}
},
{
id: 'http1',
type: 'n8n-nodes-base.httpRequest',
name: 'HTTP Request',
position: [250, 0],
parameters: {
url: 'https://api.example.com/endpoint',
method: 'GET',
authentication: 'genericCredentialType',
sendHeaders: true,
headerParameters: {
parameters: [
{
name: 'Authorization',
value: 'Bearer sk-1234567890abcdef'
}
]
}
}
}
],
connections: {
'webhook1': {
main: [[{ node: 'http1', type: 'main', index: 0 }]]
}
}
};
console.log('🧪 Testing Workflow Sanitizer\n');
console.log('Original workflow has', testWorkflow.nodes.length, 'nodes');
try {
const sanitized = WorkflowSanitizer.sanitizeWorkflow(testWorkflow);
console.log('\n✅ Sanitization successful!');
console.log('\nSanitized output:');
console.log(JSON.stringify(sanitized, null, 2));
console.log('\n📊 Metrics:');
console.log('- Workflow Hash:', sanitized.workflowHash);
console.log('- Node Count:', sanitized.nodeCount);
console.log('- Node Types:', sanitized.nodeTypes);
console.log('- Has Trigger:', sanitized.hasTrigger);
console.log('- Has Webhook:', sanitized.hasWebhook);
console.log('- Complexity:', sanitized.complexity);
} catch (error) {
console.error('❌ Sanitization failed:', error);
}

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env npx tsx
/**
* Debug workflow tracking in telemetry manager
*/
import { TelemetryManager } from '../src/telemetry/telemetry-manager';
// Get the singleton instance
const telemetry = TelemetryManager.getInstance();
const testWorkflow = {
nodes: [
{
id: 'webhook1',
type: 'n8n-nodes-base.webhook',
name: 'Webhook',
position: [0, 0],
parameters: {
path: '/test-' + Date.now(),
httpMethod: 'POST'
}
},
{
id: 'http1',
type: 'n8n-nodes-base.httpRequest',
name: 'HTTP Request',
position: [250, 0],
parameters: {
url: 'https://api.example.com/data',
method: 'GET'
}
},
{
id: 'slack1',
type: 'n8n-nodes-base.slack',
name: 'Slack',
position: [500, 0],
parameters: {
channel: '#general',
text: 'Workflow complete!'
}
}
],
connections: {
'webhook1': {
main: [[{ node: 'http1', type: 'main', index: 0 }]]
},
'http1': {
main: [[{ node: 'slack1', type: 'main', index: 0 }]]
}
}
};
console.log('🧪 Testing Workflow Tracking\n');
console.log('Workflow has', testWorkflow.nodes.length, 'nodes');
// Track the workflow
console.log('Calling trackWorkflowCreation...');
telemetry.trackWorkflowCreation(testWorkflow, true);
console.log('Waiting for async processing...');
// Wait for setImmediate to process
setTimeout(async () => {
console.log('\nForcing flush...');
await telemetry.flush();
console.log('✅ Flush complete!');
console.log('\nWorkflow should now be in the telemetry_workflows table.');
console.log('Check with: SELECT * FROM telemetry_workflows ORDER BY created_at DESC LIMIT 1;');
}, 2000);