mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 11:31:11 -05:00
Implement ML Anomaly Detection
Introduce statistical anomaly detection for metrics via edge function, hooks, and UI components. Adds detection algorithms (z-score, moving average, rate of change), anomaly storage, auto-alerts, and dashboard rendering of detected anomalies with run-once trigger and scheduling guidance.
This commit is contained in:
302
supabase/functions/detect-anomalies/index.ts
Normal file
302
supabase/functions/detect-anomalies/index.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Statistical anomaly detection algorithms
|
||||
class AnomalyDetector {
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||
const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
console.log('Starting anomaly detection run...');
|
||||
|
||||
// Get all enabled anomaly detection configurations
|
||||
const { data: configs, error: configError } = await supabase
|
||||
.from('anomaly_detection_config')
|
||||
.select('*')
|
||||
.eq('enabled', true);
|
||||
|
||||
if (configError) {
|
||||
console.error('Error fetching configs:', configError);
|
||||
throw configError;
|
||||
}
|
||||
|
||||
console.log(`Processing ${configs?.length || 0} metric configurations`);
|
||||
|
||||
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) {
|
||||
console.error(`Error fetching metric data for ${config.metric_name}:`, metricError);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = metricData as MetricData[];
|
||||
|
||||
if (!data || data.length < config.min_data_points) {
|
||||
console.log(`Insufficient data for ${config.metric_name}: ${data?.length || 0} points`);
|
||||
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;
|
||||
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) {
|
||||
console.error(`Error inserting anomaly for ${config.metric_name}:`, anomalyError);
|
||||
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);
|
||||
|
||||
console.log(`Created alert for anomaly in ${config.metric_name}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Anomaly detected: ${config.metric_name} - ${bestResult.anomalyType} (${bestResult.deviationScore.toFixed(2)}σ)`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing metric ${config.metric_name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Anomaly detection complete. Detected ${anomaliesDetected.length} anomalies`);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
anomalies_detected: anomaliesDetected.length,
|
||||
anomalies: anomaliesDetected,
|
||||
}),
|
||||
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in detect-anomalies function:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: error.message }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user