UNPKG

recoder-analytics

Version:

Comprehensive analytics and monitoring for the Recoder.xyz ecosystem

583 lines • 24.3 kB
"use strict"; /** * Multi-Channel Notification Service * * Handles sending alerts and notifications through multiple channels including * Slack, email, webhooks, and SMS with intelligent routing and fallbacks. */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.notificationService = exports.NotificationService = void 0; const shared_1 = require("@recoder/shared"); const events_1 = require("events"); const https = __importStar(require("https")); const http = __importStar(require("http")); class NotificationService extends events_1.EventEmitter { constructor() { super(); this.channels = new Map(); this.templates = new Map(); this.history = []; this.rateLimits = new Map(); this.config = { maxHistorySize: 10000, rateLimitWindow: 60000, // 1 minute defaultRateLimit: 60, // 60 notifications per minute per channel retryAttempts: 3, retryDelayMs: 2000, timeout: 10000, // 10 seconds }; this.isRunning = false; this.initializeDefaultChannels(); this.initializeDefaultTemplates(); } initializeDefaultChannels() { const defaultChannels = [ { id: 'slack_alerts', name: 'Slack Alerts Channel', type: 'slack', enabled: true, config: { webhookUrl: process.env.SLACK_WEBHOOK_URL || '', channel: '#alerts', username: 'Recoder Health Monitor', iconEmoji: ':warning:' }, priority: 9, conditions: { severities: ['high', 'critical'] } }, { id: 'email_oncall', name: 'On-Call Email', type: 'email', enabled: true, config: { smtpHost: process.env.SMTP_HOST || 'localhost', smtpPort: parseInt(process.env.SMTP_PORT || '587'), smtpUser: process.env.SMTP_USER || '', smtpPass: process.env.SMTP_PASS || '', from: process.env.ALERT_FROM_EMAIL || 'alerts@recoder.xyz', to: process.env.ONCALL_EMAIL || 'oncall@recoder.xyz' }, priority: 8, conditions: { severities: ['critical'] } }, { id: 'email_billing', name: 'Billing Team Email', type: 'email', enabled: true, config: { smtpHost: process.env.SMTP_HOST || 'localhost', smtpPort: parseInt(process.env.SMTP_PORT || '587'), smtpUser: process.env.SMTP_USER || '', smtpPass: process.env.SMTP_PASS || '', from: process.env.ALERT_FROM_EMAIL || 'alerts@recoder.xyz', to: process.env.BILLING_EMAIL || 'billing@recoder.xyz' }, priority: 7, conditions: { tags: ['budget', 'cost'] } }, { id: 'webhook_external', name: 'External Monitoring Webhook', type: 'webhook', enabled: false, // Disabled by default config: { url: process.env.EXTERNAL_WEBHOOK_URL || '', method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.WEBHOOK_TOKEN || ''}` } }, priority: 5 } ]; defaultChannels.forEach(channel => { this.channels.set(channel.id, channel); }); shared_1.Logger.info(`Initialized ${defaultChannels.length} notification channels`); } initializeDefaultTemplates() { const defaultTemplates = [ { id: 'slack_alert', name: 'Slack Alert Template', channel: 'slack', alertTemplate: '🚨 *{{severity | upper}} Alert* 🚨\n\n*{{title}}*\n{{message}}\n\n📊 *Details:*\n• Model: {{modelName || \'N/A\'}}\n• Provider: {{provider || \'N/A\'}}\n• Source: {{source}}\n• Time: {{timestamp | formatDate}}\n\n{{#if data.errorRate}}• Error Rate: {{data.errorRate}}%{{/if}}\n{{#if data.latency}}• Latency: {{data.latency}}ms{{/if}}\n{{#if data.cost}}• Cost: ${{data.cost}}{{/if}}\n\n🔗 <{{dashboardUrl}}|View Dashboard>', resolutionTemplate: '✅ *Resolved* - {{title}}\n\nResolved by: {{resolvedBy}}\nResolution time: {{resolutionTime}} minutes\n{{#if resolutionNote}}Note: {{resolutionNote}}{{/if}}', variables: ['severity', 'title', 'message', 'modelName', 'provider', 'source', 'timestamp', 'data', 'resolvedBy', 'resolutionTime', 'resolutionNote'] }, { id: 'email_alert', name: 'Email Alert Template', channel: 'email', alertTemplate: 'Subject: [{{severity | upper}}] {{title}}\n\nAlert Details:\n--------------\nSeverity: {{severity | upper}}\nModel: {{modelName || \'N/A\'}}\nProvider: {{provider || \'N/A\'}}\nSource: {{source}}\nTimestamp: {{timestamp | formatDate}}\n\nDescription:\n{{message}}\n\n{{#if data}}\nAdditional Data:\n{{#each data}}\n{{@key}}: {{this}}\n{{/each}}\n{{/if}}\n\nDashboard: {{dashboardUrl}}\n\nThis is an automated alert from Recoder Health Monitoring System.', resolutionTemplate: 'Subject: [RESOLVED] {{title}}\n\nThe following alert has been resolved:\n\nAlert: {{title}}\nResolved by: {{resolvedBy}}\nResolution time: {{resolutionTime}} minutes\n{{#if resolutionNote}}\nResolution note: {{resolutionNote}}\n{{/if}}\n\nOriginal alert timestamp: {{timestamp | formatDate}}\nResolved at: {{resolvedAt | formatDate}}\n\nThis is an automated notification from Recoder Health Monitoring System.', variables: ['severity', 'title', 'message', 'modelName', 'provider', 'source', 'timestamp', 'data', 'resolvedBy', 'resolutionTime', 'resolutionNote', 'resolvedAt'] }, { id: 'webhook_alert', name: 'Webhook Alert Template', channel: 'webhook', alertTemplate: '{\n "alert_id": "{{id}}",\n "type": "{{type}}",\n "severity": "{{severity}}",\n "title": "{{title}}",\n "message": "{{message}}",\n "source": "{{source}}",\n "model_name": "{{modelName}}",\n "provider": "{{provider}}",\n "timestamp": "{{timestamp | iso}}",\n "tags": {{tags | json}},\n "data": {{data | json}},\n "dashboard_url": "{{dashboardUrl}}"\n}', resolutionTemplate: '{\n "alert_id": "{{id}}",\n "type": "resolution",\n "title": "{{title}}",\n "resolved_by": "{{resolvedBy}}",\n "resolved_at": "{{resolvedAt | iso}}",\n "resolution_time_minutes": {{resolutionTime}},\n "resolution_note": "{{resolutionNote}}",\n "original_timestamp": "{{timestamp | iso}}"\n}', variables: ['id', 'type', 'severity', 'title', 'message', 'source', 'modelName', 'provider', 'timestamp', 'tags', 'data', 'resolvedBy', 'resolvedAt', 'resolutionTime', 'resolutionNote'] } ]; defaultTemplates.forEach(template => { this.templates.set(template.id, template); }); shared_1.Logger.info(`Initialized ${defaultTemplates.length} notification templates`); } /** * Start notification service */ async start() { if (this.isRunning) { shared_1.Logger.warn('Notification service is already running'); return; } shared_1.Logger.info('Starting notification service...'); // Test configured channels await this.testChannels(); this.isRunning = true; this.emit('serviceStarted'); shared_1.Logger.info('Notification service started successfully'); } /** * Stop notification service */ async stop() { if (!this.isRunning) { return; } shared_1.Logger.info('Stopping notification service...'); this.isRunning = false; this.emit('serviceStopped'); shared_1.Logger.info('Notification service stopped'); } /** * Send alert notification through specified channels */ async sendAlert(alert, channelIds) { const results = []; for (const channelId of channelIds) { if (!this.isRateLimited(channelId)) { const result = await this.sendToChannel(alert, channelId, 'alert'); results.push(result); // Record in history this.recordNotification(alert.id, channelId, result.success, 'Alert notification', result.error); } else { results.push({ channelId, success: false, timestamp: new Date(), error: 'Rate limited', responseTime: 0 }); } } this.emit('alertSent', alert.id, results); return results; } /** * Send resolution notification */ async sendResolution(alert, channelIds) { const results = []; for (const channelId of channelIds) { if (!this.isRateLimited(channelId)) { const result = await this.sendToChannel(alert, channelId, 'resolution'); results.push(result); // Record in history this.recordNotification(alert.id, channelId, result.success, 'Resolution notification', result.error); } } this.emit('resolutionSent', alert.id, results); return results; } /** * Add or update notification channel */ async addChannel(channel) { this.channels.set(channel.id, channel); // Test the channel await this.testChannel(channel.id); shared_1.Logger.info(`Added notification channel: ${channel.name}`); } /** * Get all notification channels */ getChannels() { return Array.from(this.channels.values()); } /** * Get notification history */ getHistory(alertId, channelId, limit = 100) { let filtered = this.history; if (alertId) { filtered = filtered.filter(h => h.alertId === alertId); } if (channelId) { filtered = filtered.filter(h => h.channelId === channelId); } return filtered .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()) .slice(0, limit); } /** * Get notification statistics */ getStatistics(timeframe = '24h') { const cutoff = this.getTimeframeCutoff(timeframe); const recentHistory = this.history.filter(h => h.timestamp >= cutoff); const totalSent = recentHistory.length; const successful = recentHistory.filter(h => h.success).length; const successRate = totalSent > 0 ? (successful / totalSent) * 100 : 0; const byChannel = {}; recentHistory.forEach(h => { if (!byChannel[h.channelId]) { byChannel[h.channelId] = { sent: 0, success: 0, successRate: 0 }; } byChannel[h.channelId].sent++; if (h.success) { byChannel[h.channelId].success++; } }); Object.keys(byChannel).forEach(channelId => { const stats = byChannel[channelId]; stats.successRate = (stats.success / stats.sent) * 100; }); // Calculate average response time (would need to track this in history) const averageResponseTime = 150; // Placeholder return { totalSent, successRate, byChannel, averageResponseTime }; } // Private helper methods async sendToChannel(alert, channelId, type) { const startTime = Date.now(); const channel = this.channels.get(channelId); if (!channel) { return { channelId, success: false, timestamp: new Date(), error: 'Channel not found', responseTime: 0 }; } if (!channel.enabled) { return { channelId, success: false, timestamp: new Date(), error: 'Channel disabled', responseTime: 0 }; } // Check channel conditions if (!this.alertMatchesChannelConditions(alert, channel)) { return { channelId, success: false, timestamp: new Date(), error: 'Alert does not match channel conditions', responseTime: 0 }; } try { let success = false; let error; switch (channel.type) { case 'slack': success = await this.sendSlackNotification(alert, channel, type); break; case 'email': success = await this.sendEmailNotification(alert, channel, type); break; case 'webhook': success = await this.sendWebhookNotification(alert, channel, type); break; case 'teams': success = await this.sendTeamsNotification(alert, channel, type); break; case 'discord': success = await this.sendDiscordNotification(alert, channel, type); break; default: error = `Unsupported channel type: ${channel.type}`; } const responseTime = Date.now() - startTime; return { channelId, success, timestamp: new Date(), error, responseTime }; } catch (err) { const responseTime = Date.now() - startTime; const error = err instanceof Error ? err.message : 'Unknown error'; return { channelId, success: false, timestamp: new Date(), error, responseTime }; } } async sendSlackNotification(alert, channel, type) { const template = this.templates.get('slack_alert'); if (!template) return false; const message = this.renderTemplate(type === 'alert' ? template.alertTemplate : template.resolutionTemplate, alert); const payload = { channel: channel.config.channel, username: channel.config.username, icon_emoji: channel.config.iconEmoji, text: message }; return this.sendHttpRequest(channel.config.webhookUrl, 'POST', payload); } async sendEmailNotification(alert, channel, type) { const template = this.templates.get('email_alert'); if (!template) return false; const message = this.renderTemplate(type === 'alert' ? template.alertTemplate : template.resolutionTemplate, alert); // Extract subject from message (assuming first line is subject) const lines = message.split('\n'); const subject = lines[0].replace('Subject: ', ''); const body = lines.slice(1).join('\n'); // In a real implementation, you would use nodemailer or similar shared_1.Logger.info(`Would send email to ${channel.config.to}: ${subject}`); return true; // Simulated success } async sendWebhookNotification(alert, channel, type) { const template = this.templates.get('webhook_alert'); if (!template) return false; const message = this.renderTemplate(type === 'alert' ? template.alertTemplate : template.resolutionTemplate, alert); const payload = JSON.parse(message); return this.sendHttpRequest(channel.config.url, channel.config.method || 'POST', payload, channel.config.headers); } async sendTeamsNotification(alert, channel, type) { // Microsoft Teams webhook implementation shared_1.Logger.info(`Would send Teams notification for alert ${alert.id}`); return true; // Simulated success } async sendDiscordNotification(alert, channel, type) { // Discord webhook implementation shared_1.Logger.info(`Would send Discord notification for alert ${alert.id}`); return true; // Simulated success } async sendHttpRequest(url, method, payload, headers) { return new Promise((resolve) => { const urlObj = new URL(url); const isHttps = urlObj.protocol === 'https:'; const client = isHttps ? https : http; const data = JSON.stringify(payload); const options = { hostname: urlObj.hostname, port: urlObj.port || (isHttps ? 443 : 80), path: urlObj.pathname + urlObj.search, method, headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }, timeout: this.config.timeout }; const req = client.request(options, (res) => { const success = res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 300; resolve(success); }); req.on('error', () => { resolve(false); }); req.on('timeout', () => { req.destroy(); resolve(false); }); req.write(data); req.end(); }); } renderTemplate(template, alert) { let rendered = template; // Simple template rendering (in production, use a proper template engine) const variables = { ...alert, dashboardUrl: `https://dashboard.recoder.xyz/alerts/${alert.id}`, resolutionTime: alert.resolvedAt && alert.timestamp ? Math.round((alert.resolvedAt.getTime() - alert.timestamp.getTime()) / 60000) : 0 }; // Replace variables Object.keys(variables).forEach(key => { const value = variables[key]; const regex = new RegExp(`{{${key}}}`, 'g'); rendered = rendered.replace(regex, String(value || '')); }); // Handle formatters (basic implementation) rendered = rendered.replace(/{{(\w+) \| upper}}/g, (match, key) => { return String(variables[key] || '').toUpperCase(); }); rendered = rendered.replace(/{{(\w+) \| formatDate}}/g, (match, key) => { const date = variables[key]; return date instanceof Date ? date.toISOString() : ''; }); return rendered; } alertMatchesChannelConditions(alert, channel) { const conditions = channel.conditions; if (!conditions) return true; if (conditions.severities && !conditions.severities.includes(alert.severity)) { return false; } if (conditions.sources && !conditions.sources.includes(alert.source)) { return false; } if (conditions.tags && !conditions.tags.some(tag => alert.tags?.includes(tag))) { return false; } return true; } isRateLimited(channelId) { const now = Date.now(); const rateLimit = this.rateLimits.get(channelId); if (!rateLimit || now >= rateLimit.resetTime) { // Reset or initialize rate limit this.rateLimits.set(channelId, { count: 1, resetTime: now + this.config.rateLimitWindow }); return false; } if (rateLimit.count >= this.config.defaultRateLimit) { return true; } rateLimit.count++; return false; } recordNotification(alertId, channelId, success, message, error) { const record = { alertId, channelId, timestamp: new Date(), success, message, error }; this.history.push(record); // Maintain history size limit if (this.history.length > this.config.maxHistorySize) { this.history.splice(0, this.history.length - this.config.maxHistorySize); } } async testChannels() { for (const [channelId, channel] of this.channels) { if (channel.enabled) { await this.testChannel(channelId); } } } async testChannel(channelId) { const channel = this.channels.get(channelId); if (!channel) return; try { // Send a test notification const testAlert = { id: 'test', type: 'system', severity: 'low', title: 'Test Notification', message: 'This is a test notification to verify channel configuration.', source: 'notification-service', timestamp: new Date(), tags: ['test'] }; await this.sendToChannel(testAlert, channelId, 'alert'); shared_1.Logger.debug(`Successfully tested channel: ${channel.name}`); } catch (error) { shared_1.Logger.warn(`Failed to test channel ${channel.name}:`, error); } } getTimeframeCutoff(timeframe) { const now = new Date(); const match = timeframe.match(/^(\d+)([mhd])$/); if (!match) return new Date(now.getTime() - 24 * 60 * 60 * 1000); // Default 24h const value = parseInt(match[1]); const unit = match[2]; let milliseconds = 0; switch (unit) { case 'm': milliseconds = value * 60 * 1000; break; case 'h': milliseconds = value * 60 * 60 * 1000; break; case 'd': milliseconds = value * 24 * 60 * 60 * 1000; break; } return new Date(now.getTime() - milliseconds); } } exports.NotificationService = NotificationService; // Export singleton instance exports.notificationService = new NotificationService(); //# sourceMappingURL=notification-service.js.map