@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
JavaScript
/**
* 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