import { serve } from 'https://deno.land/std@0.190.0/http/server.ts'; import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts'; import { addSpanEvent } from '../_shared/logger.ts'; import { corsHeaders } from '../_shared/cors.ts'; interface MetricData { timestamp: string; metric_value: number; } interface AnomalyDetectionConfig { metric_name: string; metric_category: string; enabled: boolean; sensitivity: number; lookback_window_minutes: number; detection_algorithms: string[]; min_data_points: number; alert_threshold_score: number; auto_create_alert: boolean; } interface AnomalyResult { isAnomaly: boolean; anomalyType: string; deviationScore: number; confidenceScore: number; algorithm: string; baselineValue: number; anomalyValue: number; } // Advanced ML-based anomaly detection algorithms class AnomalyDetector { // Isolation Forest approximation: Detects outliers based on isolation score static isolationForest(data: number[], currentValue: number, sensitivity: number = 0.6): AnomalyResult { if (data.length < 10) { return { isAnomaly: false, anomalyType: 'none', deviationScore: 0, confidenceScore: 0, algorithm: 'isolation_forest', baselineValue: currentValue, anomalyValue: currentValue }; } // Calculate isolation score (simplified version) // Based on how different the value is from random samples const samples = 20; let isolationScore = 0; for (let i = 0; i < samples; i++) { const randomSample = data[Math.floor(Math.random() * data.length)]; const distance = Math.abs(currentValue - randomSample); isolationScore += distance; } isolationScore = isolationScore / samples; // Normalize by standard deviation const mean = data.reduce((sum, val) => sum + val, 0) / data.length; const variance = data.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / data.length; const stdDev = Math.sqrt(variance); const normalizedScore = stdDev > 0 ? isolationScore / stdDev : 0; const isAnomaly = normalizedScore > (1 / sensitivity); return { isAnomaly, anomalyType: currentValue > mean ? 'outlier_high' : 'outlier_low', deviationScore: normalizedScore, confidenceScore: Math.min(normalizedScore / 5, 1), algorithm: 'isolation_forest', baselineValue: mean, anomalyValue: currentValue, }; } // Seasonal decomposition: Detects anomalies considering seasonal patterns static seasonalDecomposition(data: number[], currentValue: number, sensitivity: number = 2.5, period: number = 24): AnomalyResult { if (data.length < period * 2) { return { isAnomaly: false, anomalyType: 'none', deviationScore: 0, confidenceScore: 0, algorithm: 'seasonal', baselineValue: currentValue, anomalyValue: currentValue }; } // Calculate seasonal component (average of values at same position in period) const position = data.length % period; const seasonalValues: number[] = []; for (let i = position; i < data.length; i += period) { seasonalValues.push(data[i]); } const seasonalMean = seasonalValues.reduce((sum, val) => sum + val, 0) / seasonalValues.length; const seasonalStdDev = Math.sqrt( seasonalValues.reduce((sum, val) => sum + Math.pow(val - seasonalMean, 2), 0) / seasonalValues.length ); if (seasonalStdDev === 0) { return { isAnomaly: false, anomalyType: 'none', deviationScore: 0, confidenceScore: 0, algorithm: 'seasonal', baselineValue: seasonalMean, anomalyValue: currentValue }; } const deviationScore = Math.abs(currentValue - seasonalMean) / seasonalStdDev; const isAnomaly = deviationScore > sensitivity; return { isAnomaly, anomalyType: currentValue > seasonalMean ? 'seasonal_spike' : 'seasonal_drop', deviationScore, confidenceScore: Math.min(deviationScore / (sensitivity * 2), 1), algorithm: 'seasonal', baselineValue: seasonalMean, anomalyValue: currentValue, }; } // LSTM-inspired prediction: Simple exponential smoothing with trend detection static predictiveAnomaly(data: number[], currentValue: number, sensitivity: number = 2.5): AnomalyResult { if (data.length < 5) { return { isAnomaly: false, anomalyType: 'none', deviationScore: 0, confidenceScore: 0, algorithm: 'predictive', baselineValue: currentValue, anomalyValue: currentValue }; } // Triple exponential smoothing (Holt-Winters approximation) const alpha = 0.3; // Level smoothing const beta = 0.1; // Trend smoothing let level = data[0]; let trend = data[1] - data[0]; // Calculate smoothed values for (let i = 1; i < data.length; i++) { const prevLevel = level; level = alpha * data[i] + (1 - alpha) * (level + trend); trend = beta * (level - prevLevel) + (1 - beta) * trend; } // Predict next value const prediction = level + trend; // Calculate prediction error const recentData = data.slice(-10); const predictionErrors: number[] = []; for (let i = 1; i < recentData.length; i++) { const simplePrediction = recentData[i - 1]; predictionErrors.push(Math.abs(recentData[i] - simplePrediction)); } const meanError = predictionErrors.reduce((sum, err) => sum + err, 0) / predictionErrors.length; const errorStdDev = Math.sqrt( predictionErrors.reduce((sum, err) => sum + Math.pow(err - meanError, 2), 0) / predictionErrors.length ); const actualError = Math.abs(currentValue - prediction); const deviationScore = errorStdDev > 0 ? actualError / errorStdDev : 0; const isAnomaly = deviationScore > sensitivity; return { isAnomaly, anomalyType: currentValue > prediction ? 'unexpected_spike' : 'unexpected_drop', deviationScore, confidenceScore: Math.min(deviationScore / (sensitivity * 2), 1), algorithm: 'predictive', baselineValue: prediction, anomalyValue: currentValue, }; } // Ensemble method: Combines multiple algorithms for better accuracy static ensemble(data: number[], currentValue: number, sensitivity: number = 2.5): AnomalyResult { const results: AnomalyResult[] = [ this.zScore(data, currentValue, sensitivity), this.movingAverage(data, currentValue, sensitivity), this.rateOfChange(data, currentValue, sensitivity), this.isolationForest(data, currentValue, 0.6), this.predictiveAnomaly(data, currentValue, sensitivity), ]; // Count how many algorithms detected an anomaly const anomalyCount = results.filter(r => r.isAnomaly).length; const anomalyRatio = anomalyCount / results.length; // Calculate average deviation and confidence const avgDeviation = results.reduce((sum, r) => sum + r.deviationScore, 0) / results.length; const avgConfidence = results.reduce((sum, r) => sum + r.confidenceScore, 0) / results.length; // Determine anomaly type based on most common classification const typeCount = new Map(); results.forEach(r => { typeCount.set(r.anomalyType, (typeCount.get(r.anomalyType) || 0) + 1); }); let mostCommonType = 'none'; let maxCount = 0; typeCount.forEach((count, type) => { if (count > maxCount) { maxCount = count; mostCommonType = type; } }); const mean = data.reduce((sum, val) => sum + val, 0) / data.length; return { isAnomaly: anomalyRatio >= 0.4, // At least 40% of algorithms agree anomalyType: mostCommonType, deviationScore: avgDeviation, confidenceScore: Math.min(avgConfidence * anomalyRatio * 2, 1), algorithm: 'ensemble', baselineValue: mean, anomalyValue: currentValue, }; } // Z-Score algorithm: Detects outliers based on standard deviation static zScore(data: number[], currentValue: number, sensitivity: number = 3.0): AnomalyResult { if (data.length < 2) { return { isAnomaly: false, anomalyType: 'none', deviationScore: 0, confidenceScore: 0, algorithm: 'z_score', baselineValue: currentValue, anomalyValue: currentValue }; } const mean = data.reduce((sum, val) => sum + val, 0) / data.length; const variance = data.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / data.length; const stdDev = Math.sqrt(variance); if (stdDev === 0) { return { isAnomaly: false, anomalyType: 'none', deviationScore: 0, confidenceScore: 0, algorithm: 'z_score', baselineValue: mean, anomalyValue: currentValue }; } const zScore = Math.abs((currentValue - mean) / stdDev); const isAnomaly = zScore > sensitivity; return { isAnomaly, anomalyType: currentValue > mean ? 'spike' : 'drop', deviationScore: zScore, confidenceScore: Math.min(zScore / (sensitivity * 2), 1), algorithm: 'z_score', baselineValue: mean, anomalyValue: currentValue, }; } // Moving Average algorithm: Detects deviation from trend static movingAverage(data: number[], currentValue: number, sensitivity: number = 2.5, window: number = 10): AnomalyResult { if (data.length < window) { return { isAnomaly: false, anomalyType: 'none', deviationScore: 0, confidenceScore: 0, algorithm: 'moving_average', baselineValue: currentValue, anomalyValue: currentValue }; } const recentData = data.slice(-window); const ma = recentData.reduce((sum, val) => sum + val, 0) / recentData.length; const mad = recentData.reduce((sum, val) => sum + Math.abs(val - ma), 0) / recentData.length; if (mad === 0) { return { isAnomaly: false, anomalyType: 'none', deviationScore: 0, confidenceScore: 0, algorithm: 'moving_average', baselineValue: ma, anomalyValue: currentValue }; } const deviation = Math.abs(currentValue - ma) / mad; const isAnomaly = deviation > sensitivity; return { isAnomaly, anomalyType: currentValue > ma ? 'spike' : 'drop', deviationScore: deviation, confidenceScore: Math.min(deviation / (sensitivity * 2), 1), algorithm: 'moving_average', baselineValue: ma, anomalyValue: currentValue, }; } // Rate of Change algorithm: Detects sudden changes static rateOfChange(data: number[], currentValue: number, sensitivity: number = 3.0): AnomalyResult { if (data.length < 2) { return { isAnomaly: false, anomalyType: 'none', deviationScore: 0, confidenceScore: 0, algorithm: 'rate_of_change', baselineValue: currentValue, anomalyValue: currentValue }; } const previousValue = data[data.length - 1]; if (previousValue === 0) { return { isAnomaly: false, anomalyType: 'none', deviationScore: 0, confidenceScore: 0, algorithm: 'rate_of_change', baselineValue: previousValue, anomalyValue: currentValue }; } const percentChange = Math.abs((currentValue - previousValue) / previousValue) * 100; const isAnomaly = percentChange > (sensitivity * 10); // sensitivity * 10 = % threshold return { isAnomaly, anomalyType: currentValue > previousValue ? 'trend_change' : 'drop', deviationScore: percentChange / 10, confidenceScore: Math.min(percentChange / (sensitivity * 20), 1), algorithm: 'rate_of_change', baselineValue: previousValue, anomalyValue: currentValue, }; } } const handler = async (req: Request, { supabase, span, requestId }: EdgeFunctionContext) => { addSpanEvent(span, 'starting_anomaly_detection', { requestId }); // Get all enabled anomaly detection configurations const { data: configs, error: configError } = await supabase .from('anomaly_detection_config') .select('*') .eq('enabled', true); if (configError) { addSpanEvent(span, 'error_fetching_configs', { error: configError.message }); throw configError; } addSpanEvent(span, 'processing_metric_configs', { count: configs?.length || 0 }); const anomaliesDetected: any[] = []; for (const config of (configs as AnomalyDetectionConfig[])) { try { // Fetch historical data for this metric const windowStart = new Date(Date.now() - config.lookback_window_minutes * 60 * 1000); const { data: metricData, error: metricError } = await supabase .from('metric_time_series') .select('timestamp, metric_value') .eq('metric_name', config.metric_name) .gte('timestamp', windowStart.toISOString()) .order('timestamp', { ascending: true }); if (metricError) { addSpanEvent(span, 'error_fetching_metric_data', { metric: config.metric_name, error: metricError.message }); continue; } const data = metricData as MetricData[]; if (!data || data.length < config.min_data_points) { addSpanEvent(span, 'insufficient_data', { metric: config.metric_name, points: data?.length || 0 }); continue; } // Get current value (most recent) const currentValue = data[data.length - 1].metric_value; const historicalValues = data.slice(0, -1).map(d => d.metric_value); // Run detection algorithms const results: AnomalyResult[] = []; for (const algorithm of config.detection_algorithms) { let result: AnomalyResult; switch (algorithm) { case 'z_score': result = AnomalyDetector.zScore(historicalValues, currentValue, config.sensitivity); break; case 'moving_average': result = AnomalyDetector.movingAverage(historicalValues, currentValue, config.sensitivity); break; case 'rate_of_change': result = AnomalyDetector.rateOfChange(historicalValues, currentValue, config.sensitivity); break; case 'isolation_forest': result = AnomalyDetector.isolationForest(historicalValues, currentValue, 0.6); break; case 'seasonal': result = AnomalyDetector.seasonalDecomposition(historicalValues, currentValue, config.sensitivity, 24); break; case 'predictive': result = AnomalyDetector.predictiveAnomaly(historicalValues, currentValue, config.sensitivity); break; case 'ensemble': result = AnomalyDetector.ensemble(historicalValues, currentValue, config.sensitivity); break; default: continue; } if (result.isAnomaly && result.deviationScore >= config.alert_threshold_score) { results.push(result); } } // If any algorithm detected an anomaly if (results.length > 0) { // Use the result with highest confidence const bestResult = results.reduce((best, current) => current.confidenceScore > best.confidenceScore ? current : best ); // Determine severity based on deviation score const severity = bestResult.deviationScore >= 5 ? 'critical' : bestResult.deviationScore >= 4 ? 'high' : bestResult.deviationScore >= 3 ? 'medium' : 'low'; // Insert anomaly detection record const { data: anomaly, error: anomalyError } = await supabase .from('anomaly_detections') .insert({ metric_name: config.metric_name, metric_category: config.metric_category, anomaly_type: bestResult.anomalyType, severity, baseline_value: bestResult.baselineValue, anomaly_value: bestResult.anomalyValue, deviation_score: bestResult.deviationScore, confidence_score: bestResult.confidenceScore, detection_algorithm: bestResult.algorithm, time_window_start: windowStart.toISOString(), time_window_end: new Date().toISOString(), metadata: { algorithms_run: config.detection_algorithms, total_data_points: data.length, sensitivity: config.sensitivity, }, }) .select() .single(); if (anomalyError) { addSpanEvent(span, 'error_inserting_anomaly', { metric: config.metric_name, error: anomalyError.message }); continue; } anomaliesDetected.push(anomaly); // Auto-create alert if configured if (config.auto_create_alert && severity in ['critical', 'high']) { const { data: alert, error: alertError } = await supabase .from('system_alerts') .insert({ alert_type: 'anomaly_detected', severity, message: `Anomaly detected in ${config.metric_name}: ${bestResult.anomalyType} (${bestResult.deviationScore.toFixed(2)}σ deviation)`, metadata: { anomaly_id: anomaly.id, metric_name: config.metric_name, baseline_value: bestResult.baselineValue, anomaly_value: bestResult.anomalyValue, algorithm: bestResult.algorithm, }, }) .select() .single(); if (!alertError && alert) { // Update anomaly with alert_id await supabase .from('anomaly_detections') .update({ alert_created: true, alert_id: alert.id }) .eq('id', anomaly.id); addSpanEvent(span, 'alert_created', { metric: config.metric_name, alertId: alert.id }); } } addSpanEvent(span, 'anomaly_detected', { metric: config.metric_name, type: bestResult.anomalyType, deviation: bestResult.deviationScore.toFixed(2) }); } } catch (error) { addSpanEvent(span, 'error_processing_metric', { metric: config.metric_name, error: error instanceof Error ? error.message : String(error) }); } } addSpanEvent(span, 'anomaly_detection_complete', { detected: anomaliesDetected.length }); return new Response( JSON.stringify({ success: true, anomalies_detected: anomaliesDetected.length, anomalies: anomaliesDetected, }), { status: 200, headers: { 'Content-Type': 'application/json' }, } ); }; serve(createEdgeFunction({ name: 'detect-anomalies', requireAuth: false, corsHeaders, enableTracing: true, }, handler));