recoder-analytics
Version:
Comprehensive analytics and monitoring for the Recoder.xyz ecosystem
583 lines ⢠24.3 kB
JavaScript
"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