jaxon-optimizely-dxp-mcp
Version:
AI-powered automation for Optimizely DXP - deploy, monitor, and manage environments through natural conversations
562 lines (495 loc) • 18 kB
JavaScript
/**
* Telemetry Module
* Anonymous usage analytics and error tracking for improvement
* Part of Jaxon Digital Optimizely DXP MCP Server
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const https = require('https');
class Telemetry {
constructor() {
// Check if telemetry is enabled (opt-out - enabled by default)
this.enabled = process.env.OPTIMIZELY_MCP_TELEMETRY !== 'false' &&
process.env.MCP_TELEMETRY !== 'false';
// Anonymous session ID
this.sessionId = this.generateSessionId();
// Telemetry endpoint - must be explicitly configured for enterprise use
// Default endpoint managed internally by Jaxon Digital
this.endpoint = process.env.TELEMETRY_ENDPOINT || 'https://accelerator.jaxondigital.com/api/telemetry/mcp';
// Local storage for offline events
this.localStoragePath = path.join(os.tmpdir(), 'optimizely-mcp-telemetry');
this.pendingEvents = [];
// Metrics collection
this.metrics = {
sessionStart: Date.now(),
toolUsage: {},
errors: [],
performance: {},
environment: this.getEnvironmentInfo()
};
// Initialize local storage
this.initializeStorage();
// Send pending events on startup
if (this.enabled) {
this.sendPendingEvents();
}
}
/**
* Generate anonymous session ID
*/
generateSessionId() {
const data = `${Date.now()}-${Math.random()}-${os.hostname()}`;
return crypto.createHash('sha256').update(data).digest('hex').substring(0, 16);
}
/**
* Get anonymous environment information
*/
getEnvironmentInfo() {
return {
platform: os.platform(),
nodeVersion: process.version,
mpcVersion: this.getPackageVersion(),
osVersion: os.release(),
arch: os.arch(),
locale: process.env.LANG || 'unknown',
isCI: process.env.CI === 'true',
isDevelopment: process.env.NODE_ENV === 'development',
hasMultipleProjects: !!process.env.OPTIMIZELY_PROJECTS
};
}
/**
* Get package version
*/
getPackageVersion() {
try {
const packageJson = require('../package.json');
return packageJson.version;
} catch {
return 'unknown';
}
}
/**
* Initialize local storage
*/
initializeStorage() {
try {
if (!fs.existsSync(this.localStoragePath)) {
fs.mkdirSync(this.localStoragePath, { recursive: true });
}
// Load pending events
const eventsFile = path.join(this.localStoragePath, 'pending.json');
if (fs.existsSync(eventsFile)) {
const data = fs.readFileSync(eventsFile, 'utf8');
this.pendingEvents = JSON.parse(data);
}
} catch (error) {
// Silently fail - telemetry should never break the app
if (process.env.DEBUG) {
console.error('Telemetry storage init failed:', error.message);
}
}
}
/**
* Track tool usage
*/
trackToolUsage(toolName, args = {}) {
if (!this.enabled) return;
try {
// Increment usage counter
if (!this.metrics.toolUsage[toolName]) {
this.metrics.toolUsage[toolName] = {
count: 0,
firstUsed: Date.now(),
lastUsed: Date.now(),
errors: 0,
avgDuration: 0,
environments: new Set()
};
}
const tool = this.metrics.toolUsage[toolName];
tool.count++;
tool.lastUsed = Date.now();
// Track which environments are used
if (args.environment) {
tool.environments.add(args.environment);
}
// Create event
const event = {
type: 'tool_usage',
tool: toolName,
timestamp: Date.now(),
sessionId: this.sessionId,
environment: args.environment,
hasCredentials: !!(args.apiKey || args.projectId)
};
this.queueEvent(event);
} catch (error) {
// Silently fail
if (process.env.DEBUG) {
console.error('Telemetry tool tracking failed:', error.message);
}
}
}
/**
* Track errors
*/
trackError(error, context = {}) {
if (!this.enabled) return;
try {
const errorInfo = {
type: 'error',
timestamp: Date.now(),
sessionId: this.sessionId,
error: {
type: error.type || 'unknown',
code: error.code,
// Don't send actual error messages (might contain sensitive data)
category: this.categorizeError(error),
isRetryable: error.retryable || false
},
context: {
tool: context.tool,
operation: context.operation,
environment: context.environment
}
};
this.metrics.errors.push(errorInfo);
this.queueEvent(errorInfo);
} catch (err) {
// Silently fail
if (process.env.DEBUG) {
console.error('Telemetry error tracking failed:', err.message);
}
}
}
/**
* Track performance metrics
*/
trackPerformance(operation, duration, metadata = {}) {
if (!this.enabled) return;
try {
if (!this.metrics.performance[operation]) {
this.metrics.performance[operation] = {
count: 0,
totalDuration: 0,
avgDuration: 0,
minDuration: duration,
maxDuration: duration
};
}
const perf = this.metrics.performance[operation];
perf.count++;
perf.totalDuration += duration;
perf.avgDuration = perf.totalDuration / perf.count;
perf.minDuration = Math.min(perf.minDuration, duration);
perf.maxDuration = Math.max(perf.maxDuration, duration);
const event = {
type: 'performance',
operation,
duration,
timestamp: Date.now(),
sessionId: this.sessionId,
metadata: {
size: metadata.size,
environment: metadata.environment,
success: metadata.success !== false
}
};
this.queueEvent(event);
} catch (error) {
// Silently fail
if (process.env.DEBUG) {
console.error('Telemetry performance tracking failed:', error.message);
}
}
}
/**
* Track deployment patterns
*/
trackDeployment(sourceEnv, targetEnv, options = {}) {
if (!this.enabled) return;
try {
const event = {
type: 'deployment',
timestamp: Date.now(),
sessionId: this.sessionId,
deployment: {
path: `${sourceEnv}->${targetEnv}`,
isUpward: this.isUpwardPath(sourceEnv, targetEnv),
hasCode: options.includeCode,
hasContent: options.includeContent,
directDeploy: options.directDeploy,
useMaintenancePage: options.useMaintenancePage
}
};
this.queueEvent(event);
} catch (error) {
// Silently fail
if (process.env.DEBUG) {
console.error('Telemetry deployment tracking failed:', error.message);
}
}
}
/**
* Categorize errors for analytics
*/
categorizeError(error) {
const message = (error.message || '').toLowerCase();
if (message.includes('timeout')) return 'timeout';
if (message.includes('auth') || message.includes('401') || message.includes('403')) return 'authentication';
if (message.includes('not found') || message.includes('404')) return 'not_found';
if (message.includes('network') || message.includes('econnrefused')) return 'network';
if (message.includes('rate') || message.includes('429')) return 'rate_limit';
if (message.includes('invalid')) return 'validation';
if (message.includes('module')) return 'module_error';
if (message.includes('permission')) return 'permission';
return 'other';
}
/**
* Check if deployment path is upward
*/
isUpwardPath(source, target) {
const envOrder = { 'Integration': 0, 'Preproduction': 1, 'Production': 2 };
return (envOrder[target] || 0) > (envOrder[source] || 0);
}
/**
* Queue event for sending
*/
queueEvent(event) {
if (!this.enabled) return;
// Add event to queue
this.pendingEvents.push(event);
// Always try to send immediately if we have an endpoint
// This ensures events are sent even in short-lived MCP sessions
if (this.endpoint) {
// Send immediately and don't wait for response
this.sendEvents([event]).catch(() => {
// If send fails, events remain in pendingEvents for retry
this.saveEventsLocally();
});
} else {
this.saveEventsLocally();
}
}
/**
* Save events to local storage
*/
saveEventsLocally() {
try {
const eventsFile = path.join(this.localStoragePath, 'pending.json');
// Keep only last 1000 events to prevent unlimited growth
if (this.pendingEvents.length > 1000) {
this.pendingEvents = this.pendingEvents.slice(-1000);
}
fs.writeFileSync(eventsFile, JSON.stringify(this.pendingEvents, null, 2));
} catch (error) {
// Silently fail
if (process.env.DEBUG) {
console.error('Failed to save telemetry locally:', error.message);
}
}
}
/**
* Send events to telemetry endpoint
*/
async sendEvents(events) {
if (!this.enabled || !this.endpoint || events.length === 0) return;
try {
const data = JSON.stringify({
events,
session: {
id: this.sessionId,
version: this.metrics.environment.mpcVersion,
platform: this.metrics.environment.platform
}
});
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
'User-Agent': `jaxon-optimizely-mcp/${this.metrics.environment.mpcVersion}`
},
timeout: 5000 // 5 second timeout
};
// Parse endpoint URL
const url = new URL(this.endpoint);
options.hostname = url.hostname;
options.port = url.port || 443;
options.path = url.pathname;
return new Promise((resolve) => {
const req = https.request(options, (res) => {
if (res.statusCode === 200 || res.statusCode === 204) {
// Success - remove sent events from pending
this.pendingEvents = this.pendingEvents.filter(e => !events.includes(e));
this.saveEventsLocally();
}
resolve();
});
req.on('error', () => {
// Silently fail - keep events for retry
resolve();
});
req.on('timeout', () => {
req.destroy();
resolve();
});
req.write(data);
req.end();
});
} catch (error) {
// Silently fail
if (process.env.DEBUG) {
console.error('Failed to send telemetry:', error.message);
}
}
}
/**
* Send all pending events
*/
async sendPendingEvents() {
if (!this.enabled || !this.endpoint || this.pendingEvents.length === 0) return;
// Send in batches of 50
const batchSize = 50;
while (this.pendingEvents.length > 0) {
const batch = this.pendingEvents.slice(0, batchSize);
await this.sendEvents(batch);
// If events weren't sent (no endpoint), stop trying
if (this.pendingEvents.length === batch.length) {
break;
}
}
}
/**
* Force flush events (for MCP sessions)
* This ensures events are sent even in short-lived processes
*/
async flush() {
if (!this.enabled) return;
try {
// Send any pending events immediately
await this.sendPendingEvents();
// Also send session summary if we have tool usage
if (Object.keys(this.metrics.toolUsage).length > 0) {
const summary = this.getSessionSummary();
if (summary) {
await this.sendEvents([summary]);
}
}
} catch (error) {
// Silently fail but save events locally
this.saveEventsLocally();
if (process.env.DEBUG) {
console.error('Telemetry flush failed:', error.message);
}
}
}
/**
* Get session summary
*/
getSessionSummary() {
if (!this.enabled) return null;
const duration = Date.now() - this.metrics.sessionStart;
const toolCount = Object.keys(this.metrics.toolUsage).length;
const totalUsage = Object.values(this.metrics.toolUsage).reduce((sum, tool) => sum + tool.count, 0);
return {
type: 'session_summary',
timestamp: Date.now(),
sessionId: this.sessionId,
duration,
summary: {
toolsUsed: toolCount,
totalOperations: totalUsage,
errorCount: this.metrics.errors.length,
topTools: this.getTopTools(5),
environment: this.metrics.environment
}
};
}
/**
* Get top used tools
*/
getTopTools(limit = 5) {
return Object.entries(this.metrics.toolUsage)
.sort((a, b) => b[1].count - a[1].count)
.slice(0, limit)
.map(([name, data]) => ({
name,
count: data.count,
environments: Array.from(data.environments || [])
}));
}
/**
* Shutdown telemetry (send final summary)
*/
async shutdown() {
if (!this.enabled) return;
try {
// Send session summary
const summary = this.getSessionSummary();
if (summary) {
await this.sendEvents([summary]);
}
// Send any remaining pending events
await this.sendPendingEvents();
} catch (error) {
// Silently fail
if (process.env.DEBUG) {
console.error('Telemetry shutdown failed:', error.message);
}
}
}
/**
* Get privacy-safe analytics report
*/
getAnalyticsReport() {
if (!this.enabled) return null;
return {
enabled: true,
sessionId: this.sessionId,
uptime: Date.now() - this.metrics.sessionStart,
tools: {
count: Object.keys(this.metrics.toolUsage).length,
totalUsage: Object.values(this.metrics.toolUsage).reduce((sum, t) => sum + t.count, 0),
top: this.getTopTools(3)
},
errors: {
count: this.metrics.errors.length,
categories: this.metrics.errors.reduce((acc, err) => {
acc[err.error.category] = (acc[err.error.category] || 0) + 1;
return acc;
}, {})
},
performance: Object.entries(this.metrics.performance).reduce((acc, [op, data]) => {
acc[op] = {
avgDuration: Math.round(data.avgDuration),
operations: data.count
};
return acc;
}, {})
};
}
}
// Singleton instance
let telemetryInstance = null;
/**
* Get or create telemetry instance
*/
function getTelemetry() {
if (!telemetryInstance) {
telemetryInstance = new Telemetry();
// Register shutdown handler
process.on('beforeExit', () => {
if (telemetryInstance) {
telemetryInstance.shutdown();
}
});
}
return telemetryInstance;
}
module.exports = {
Telemetry,
getTelemetry
};