UNPKG

@varia-bly/variably-sdk

Version:

Official JavaScript/TypeScript SDK for Variably feature flags, experimentation, LLM experiments with React hooks, and real-time dynamic configurations

385 lines 13.3 kB
/** * Real-time alerting system for metric thresholds * Implements future enhancement features from the metrics collection documentation */ export class RealTimeAlerting { constructor() { this.thresholds = new Map(); this.channels = []; this.metricData = new Map(); this.alertHistory = []; this.timers = new Map(); this.cooldownTracker = new Map(); } /** * Add a metric threshold for monitoring */ addThreshold(threshold) { this.thresholds.set(threshold.id, threshold); if (threshold.enabled) { this.startMonitoring(threshold); } } /** * Remove a metric threshold */ removeThreshold(thresholdId) { this.thresholds.delete(thresholdId); this.stopMonitoring(thresholdId); } /** * Update a metric threshold */ updateThreshold(threshold) { const existing = this.thresholds.get(threshold.id); if (existing) { this.stopMonitoring(threshold.id); } this.thresholds.set(threshold.id, threshold); if (threshold.enabled) { this.startMonitoring(threshold); } } /** * Add an alert channel */ addChannel(channel) { this.channels.push(channel); } /** * Remove an alert channel */ removeChannel(channelType) { this.channels = this.channels.filter(channel => channel.type !== channelType); } /** * Record a metric data point */ recordMetric(dataPoint) { const metricName = dataPoint.metricName; if (!this.metricData.has(metricName)) { this.metricData.set(metricName, []); } const metricHistory = this.metricData.get(metricName); metricHistory.push(dataPoint); // Keep only recent data points (last 24 hours) const cutoffTime = new Date(Date.now() - 24 * 60 * 60 * 1000); this.metricData.set(metricName, metricHistory.filter(dp => dp.timestamp >= cutoffTime)); // Immediately check thresholds for this metric this.checkThresholdsForMetric(metricName); } /** * Get alert history */ getAlertHistory(hours = 24) { const cutoffTime = new Date(Date.now() - hours * 60 * 60 * 1000); return this.alertHistory.filter(alert => alert.triggeredAt >= cutoffTime); } /** * Get current metric value */ getCurrentMetricValue(metricName) { const data = this.metricData.get(metricName); if (!data || data.length === 0) return undefined; return data[data.length - 1].value; } /** * Get metric statistics over time window */ getMetricStats(metricName, timeWindow) { const data = this.metricData.get(metricName); if (!data || data.length === 0) return undefined; const cutoffTime = new Date(Date.now() - timeWindow); const recentData = data.filter(dp => dp.timestamp >= cutoffTime); if (recentData.length === 0) return undefined; const values = recentData.map(dp => dp.value); const avg = values.reduce((sum, val) => sum + val, 0) / values.length; const min = Math.min(...values); const max = Math.max(...values); // Calculate trend (compare first half vs second half) const halfPoint = Math.floor(recentData.length / 2); const firstHalf = recentData.slice(0, halfPoint); const secondHalf = recentData.slice(halfPoint); let trend = 'stable'; if (firstHalf.length > 0 && secondHalf.length > 0) { const firstAvg = firstHalf.reduce((sum, dp) => sum + dp.value, 0) / firstHalf.length; const secondAvg = secondHalf.reduce((sum, dp) => sum + dp.value, 0) / secondHalf.length; const changePercent = Math.abs(secondAvg - firstAvg) / firstAvg; if (changePercent > 0.1) { // 10% change threshold trend = secondAvg > firstAvg ? 'up' : 'down'; } } return { avg, min, max, count: recentData.length, trend }; } /** * Destroy the alerting system and clean up resources */ destroy() { // Clear all timers for (const timer of this.timers.values()) { clearInterval(timer); } this.timers.clear(); // Clear data this.thresholds.clear(); this.metricData.clear(); this.alertHistory.length = 0; this.channels.length = 0; this.cooldownTracker.clear(); } /** * Start monitoring a threshold */ startMonitoring(threshold) { const timer = setInterval(() => { this.evaluateThreshold(threshold); }, threshold.evaluationInterval); this.timers.set(threshold.id, timer); } /** * Stop monitoring a threshold */ stopMonitoring(thresholdId) { const timer = this.timers.get(thresholdId); if (timer) { clearInterval(timer); this.timers.delete(thresholdId); } } /** * Check thresholds immediately for a specific metric */ checkThresholdsForMetric(metricName) { for (const threshold of this.thresholds.values()) { if (threshold.metricName === metricName && threshold.enabled) { this.evaluateThreshold(threshold); } } } /** * Evaluate a single threshold */ evaluateThreshold(threshold) { // Check cooldown if (this.isInCooldown(threshold.id)) { return; } const stats = this.getMetricStats(threshold.metricName, threshold.timeWindow); if (!stats) return; const currentValue = stats.avg; // Use average over time window const breached = this.checkThresholdBreach(currentValue, threshold.threshold, threshold.operator); if (breached) { this.triggerAlert(threshold, currentValue, stats); // Set cooldown if (threshold.cooldownPeriod) { this.cooldownTracker.set(threshold.id, new Date(Date.now() + threshold.cooldownPeriod)); } } } /** * Check if threshold is breached */ checkThresholdBreach(value, threshold, operator) { switch (operator) { case 'gt': return value > threshold; case 'lt': return value < threshold; case 'gte': return value >= threshold; case 'lte': return value <= threshold; case 'eq': return value === threshold; case 'neq': return value !== threshold; default: return false; } } /** * Check if threshold is in cooldown period */ isInCooldown(thresholdId) { const cooldownEnd = this.cooldownTracker.get(thresholdId); if (!cooldownEnd) return false; if (new Date() >= cooldownEnd) { this.cooldownTracker.delete(thresholdId); return false; } return true; } /** * Trigger an alert */ triggerAlert(threshold, currentValue, stats) { const alert = { id: `alert_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, thresholdId: threshold.id, metricName: threshold.metricName, currentValue, thresholdValue: threshold.threshold, operator: threshold.operator, severity: threshold.severity, triggeredAt: new Date(), message: this.formatAlertMessage(threshold, currentValue, stats), context: { stats, threshold: threshold } }; this.alertHistory.push(alert); // Send alert through all enabled channels this.sendAlertThroughChannels(alert); } /** * Format alert message */ formatAlertMessage(threshold, currentValue, stats) { const operatorText = this.getOperatorText(threshold.operator); return `🚨 ${threshold.severity.toUpperCase()}: ${threshold.name}\n` + `Metric "${threshold.metricName}" is ${currentValue.toFixed(2)} ${operatorText} ${threshold.threshold}\n` + `Trend: ${stats.trend} | Min: ${stats.min.toFixed(2)} | Max: ${stats.max.toFixed(2)}`; } /** * Get human-readable operator text */ getOperatorText(operator) { const operatorMap = { 'gt': 'greater than', 'lt': 'less than', 'gte': 'greater than or equal to', 'lte': 'less than or equal to', 'eq': 'equal to', 'neq': 'not equal to' }; return operatorMap[operator] || operator; } /** * Send alert through all configured channels */ async sendAlertThroughChannels(alert) { const promises = this.channels .filter(channel => channel.enabled) .map(channel => this.sendAlertToChannel(alert, channel)); try { await Promise.allSettled(promises); } catch (error) { console.error('Failed to send alerts through some channels:', error); } } /** * Send alert to specific channel */ async sendAlertToChannel(alert, channel) { try { switch (channel.type) { case 'console': this.sendConsoleAlert(alert, channel.config); break; case 'webhook': await this.sendWebhookAlert(alert, channel.config); break; case 'slack': await this.sendSlackAlert(alert, channel.config); break; case 'email': // Email implementation would require email service integration console.warn('Email alerts not implemented yet'); break; } } catch (error) { console.error(`Failed to send alert through ${channel.type}:`, error); } } /** * Send console alert */ sendConsoleAlert(alert, config) { const logMethod = config.logLevel === 'error' ? console.error : config.logLevel === 'warn' ? console.warn : console.info; logMethod(`[VARIABLY ALERT] ${alert.message}`); } /** * Send webhook alert */ async sendWebhookAlert(alert, config) { const retries = config.retryAttempts || 3; let lastError; for (let i = 0; i < retries; i++) { try { const response = await fetch(config.url, { method: config.method, headers: { 'Content-Type': 'application/json', ...config.headers }, body: JSON.stringify({ alert, timestamp: alert.triggeredAt.toISOString(), source: 'variably-sdk' }) }); if (response.ok) { return; // Success } throw new Error(`HTTP ${response.status}: ${response.statusText}`); } catch (error) { lastError = error; if (i < retries - 1) { // Wait before retry (exponential backoff) await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000)); } } } throw lastError; } /** * Send Slack alert */ async sendSlackAlert(alert, config) { const slackMessage = { channel: config.channel, username: config.username || 'Variably Alerts', icon_emoji: config.iconEmoji || ':warning:', attachments: [{ color: alert.severity === 'critical' ? 'danger' : alert.severity === 'warning' ? 'warning' : 'good', title: `${alert.severity.toUpperCase()} Alert: ${alert.metricName}`, text: alert.message, fields: [ { title: 'Current Value', value: alert.currentValue.toFixed(2), short: true }, { title: 'Threshold', value: `${this.getOperatorText(alert.operator)} ${alert.thresholdValue}`, short: true } ], timestamp: Math.floor(alert.triggeredAt.getTime() / 1000) }] }; const response = await fetch(config.webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(slackMessage) }); if (!response.ok) { throw new Error(`Slack API error: ${response.status} ${response.statusText}`); } } } //# sourceMappingURL=alerting.js.map