Saltar al contenido

Detección de anomalías para el monitoreo de aplicaciones

Detección de anomalías en métricas de aplicaciones: métodos estadísticos, z-scores, medias móviles, descomposición estacional y alertas sin fatiga.

5 min de lectura
Gráfico de series temporales mostrando patrones normales de tráfico con una anomalía resaltada por un algoritmo de detección

Los umbrales estáticos son el enfoque por defecto para las alertas de monitoreo: "alertar cuando la tasa de errores supere el 5%" o "alertar cuando la latencia p99 supere los 500ms". Funcionan hasta que cambian tus patrones de tráfico: picos en festivos, caídas los fines de semana, crecimiento gradual. Una tasa de errores del 3% puede ser normal durante un pico de tráfico, pero alarmante a las 3 AM cuando solo los servicios internos llaman a tu API.

La detección de anomalías reemplaza los umbrales estáticos con líneas base dinámicas. En lugar de "¿está este número por encima de X?", pregunta "¿es este número inusual para este momento y contexto?". Esto reduce las falsas alertas durante cambios de tráfico esperados, mientras detecta incidentes reales que los umbrales estáticos pasan por alto.

Líneas base estadísticas con Z-Scores

Un z-score mide a cuántas desviaciones estándar está un valor de la media. Un z-score superior a 3 significa que el valor está a más de 3 desviaciones estándar de lo normal: aproximadamente un 0.3% de probabilidad de ocurrir de forma natural. Es el detector de anomalías más simple.

pypython
import numpy as np
from dataclasses import dataclass
 
@dataclass
class AnomalyResult:
    value: float
    mean: float
    std: float
    z_score: float
    is_anomaly: bool
 
def detect_zscore_anomaly(
    values: list[float],
    current: float,
    threshold: float = 3.0
) -> AnomalyResult:
    """Detect anomalies using z-score on recent history."""
    arr = np.array(values)
    mean = float(np.mean(arr))
    std = float(np.std(arr))
 
    # Avoid division by zero for constant metrics
    if std == 0:
        z_score = 0.0 if current == mean else float('inf')
    else:
        z_score = abs(current - mean) / std
 
    return AnomalyResult(
        value=current,
        mean=mean,
        std=std,
        z_score=z_score,
        is_anomaly=z_score > threshold
    )
 
# Example: error rate over the last 24 hours (1-minute intervals)
error_rates = [0.02, 0.018, 0.022, 0.019, ...]  # 1440 data points
current_rate = 0.085
 
result = detect_zscore_anomaly(error_rates, current_rate)
# z_score: 8.2, is_anomaly: True
# Mean 2%, current 8.5% → clearly anomalous

Media móvil exponencial (EMA)

Los z-scores tratan todos los datos históricos por igual. EMA da más peso a los valores recientes, adaptándose más rápido a las tendencias. El factor de suavizado alpha controla la rapidez con la que se ajusta la línea base: un alpha mayor significa una adaptación más rápida.

tstypescript
class EMADetector {
  private ema: number | null = null;
  private emaVariance: number = 0;
 
  constructor(
    private alpha: number = 0.1,
    private threshold: number = 3.0
  ) {}
 
  update(
    value: number
  ): { isAnomaly: boolean; expected: number; deviation: number } {
    if (this.ema === null) {
      this.ema = value;
      return { isAnomaly: false, expected: value, deviation: 0 };
    }
 
    // Update EMA (exponentially weighted moving average)
    const prevEma = this.ema;
    this.ema = this.alpha * value + (1 - this.alpha) * this.ema;
 
    // Update variance estimate
    const diff = value - prevEma;
    this.emaVariance =
      this.alpha * diff * diff +
      (1 - this.alpha) * this.emaVariance;
 
    const stdDev = Math.sqrt(this.emaVariance);
    const deviation =
      stdDev > 0 ? Math.abs(value - prevEma) / stdDev : 0;
 
    return {
      isAnomaly: deviation > this.threshold,
      expected: prevEma,
      deviation,
    };
  }
}
 
// Usage: detect latency anomalies
const latencyDetector = new EMADetector(0.05, 3.0);
 
// Feed in normal values to establish baseline
for (const latency of normalLatencies) {
  latencyDetector.update(latency);
}
 
// Then check new values
const result = latencyDetector.update(850);
// { isAnomaly: true, expected: 120, deviation: 4.2 }

Descomposición estacional

Muchas métricas tienen patrones diarios y semanales. El volumen de peticiones alcanza su pico al mediodía y cae a medianoche. Un aumento del 50% del tráfico al mediodía es normal; a las 3 AM es sospechoso. La descomposición estacional separa los componentes de tendencia, patrón estacional y residuo.

pypython
from dataclasses import dataclass
import numpy as np
 
@dataclass
class SeasonalBaseline:
    hourly_means: np.ndarray  # 24 values (one per hour)
    hourly_stds: np.ndarray   # 24 values
    day_of_week_factors: np.ndarray  # 7 values
 
def build_seasonal_baseline(
    timestamps: list[float],
    values: list[float],
    min_weeks: int = 2
) -> SeasonalBaseline:
    """Build hourly baselines from historical data."""
    from datetime import datetime
 
    # Group values by hour-of-day
    hourly_buckets: dict[int, list[float]] = {h: [] for h in range(24)}
    dow_buckets: dict[int, list[float]] = {d: [] for d in range(7)}
 
    for ts, val in zip(timestamps, values):
        dt = datetime.fromtimestamp(ts)
        hourly_buckets[dt.hour].append(val)
        dow_buckets[dt.weekday()].append(val)
 
    hourly_means = np.array([
        np.mean(hourly_buckets[h]) if hourly_buckets[h] else 0
        for h in range(24)
    ])
    hourly_stds = np.array([
        np.std(hourly_buckets[h]) if len(hourly_buckets[h]) > 1 else 1
        for h in range(24)
    ])
 
    global_mean = np.mean(values) if values else 1
    day_factors = np.array([
        np.mean(dow_buckets[d]) / global_mean if dow_buckets[d] else 1
        for d in range(7)
    ])
 
    return SeasonalBaseline(hourly_means, hourly_stds, day_factors)
 
def is_seasonal_anomaly(
    baseline: SeasonalBaseline,
    current_value: float,
    hour: int,
    day_of_week: int,
    threshold: float = 3.0
) -> bool:
    """Check if value is anomalous for this time and day."""
    expected = baseline.hourly_means[hour] * baseline.day_of_week_factors[day_of_week]
    std = baseline.hourly_stds[hour]
 
    if std == 0:
        return current_value != expected
 
    z = abs(current_value - expected) / std
    return z > threshold

Construcción de un pipeline de alertas

La detección de anomalías solo es útil si produce alertas accionables sin ahogar al ingeniero de guardia en ruido.

tstypescript
// ❌ Alerting on raw anomaly detection output
// Every z-score > 3 triggers a page
// Result: 15 alerts per day, most are benign blips
// On-call engineer ignores all alerts after 2 days
 
// ✅ Multi-stage alerting pipeline
interface AlertPipeline {
  stage1_detection: {
    description: "Raw anomaly detection on 1-minute data";
    output: "Boolean: is this data point anomalous?";
  };
  stage2_confirmation: {
    description: "Require N consecutive anomalous points";
    config: { consecutiveRequired: 3; windowMinutes: 5 };
    output: "Boolean: sustained anomaly confirmed?";
  };
  stage3_severity: {
    description: "Classify severity based on deviation magnitude";
    config: {
      warning: "z-score 3-5 OR error rate increase 2-5x";
      critical: "z-score > 5 OR error rate increase > 5x";
    };
  };
  stage4_dedup: {
    description: "Suppress duplicate alerts for same incident";
    config: { silenceMinutes: 30; groupBy: "metric_name + service" };
  };
}
tstypescript
class AlertManager {
  private activeAlerts = new Map<string, { since: Date; count: number }>();
 
  shouldAlert(
    metricKey: string,
    isAnomaly: boolean,
    severity: "warning" | "critical"
  ): { alert: boolean; action: "page" | "slack" | "none" } {
    const existing = this.activeAlerts.get(metricKey);
 
    if (!isAnomaly) {
      // Anomaly resolved
      if (existing) {
        this.activeAlerts.delete(metricKey);
        return { alert: true, action: "slack" }; // Resolution notification
      }
      return { alert: false, action: "none" };
    }
 
    if (existing) {
      existing.count++;
      // Already alerted, suppress until silence window expires
      return { alert: false, action: "none" };
    }
 
    // New anomaly
    this.activeAlerts.set(metricKey, { since: new Date(), count: 1 });
 
    return {
      alert: true,
      action: severity === "critical" ? "page" : "slack",
    };
  }
}

Combinación de múltiples señales

La detección de anomalías con una sola métrica produce falsos positivos. Combinar métricas correlacionadas mejora drásticamente la precisión.

tstypescript
interface CorrelatedCheck {
  primary: { metric: string; isAnomaly: boolean };
  supporting: Array<{ metric: string; isAnomaly: boolean }>;
  confidence: number;
}
 
function correlatedAnomalyCheck(
  checks: CorrelatedCheck
): { isReal: boolean; confidence: number } {
  if (!checks.primary.isAnomaly) {
    return { isReal: false, confidence: 0 };
  }
 
  const supportingAnomaly = checks.supporting.filter(
    (s) => s.isAnomaly
  ).length;
  const totalSupporting = checks.supporting.length;
 
  // Higher confidence when multiple related metrics are anomalous
  const correlationScore =
    totalSupporting > 0 ? supportingAnomaly / totalSupporting : 0;
 
  const confidence = 0.5 + 0.5 * correlationScore;
 
  return {
    isReal: confidence > 0.6,
    confidence,
  };
}
 
// Example: high error rate is more likely real if:
// - Latency is also elevated (correlated)
// - Traffic volume is normal (not a traffic spike causing errors)
// - CPU usage is elevated (resource pressure)
const check: CorrelatedCheck = {
  primary: { metric: "error_rate", isAnomaly: true },
  supporting: [
    { metric: "p99_latency", isAnomaly: true },
    { metric: "request_rate", isAnomaly: false },
    { metric: "cpu_usage", isAnomaly: true },
  ],
  confidence: 0,
};
// 2/3 supporting anomalies → confidence: 0.83 → alert

Puntos clave

  1. Los umbrales estáticos fallan cuando cambian los patrones de tráfico: reemplaza "alertar por encima de X" con líneas base dinámicas que tengan en cuenta los patrones por hora del día y día de la semana
  2. Los z-scores son el detector de anomalías más simple: si un valor está a más de 3 desviaciones estándar de la media, es inusual; esto detecta el 99.7% de las anomalías genuinas con un mínimo de falsos positivos
  3. Exige anomalías sostenidas antes de alertar: un solo punto anómalo puede ser ruido; requiere 3 o más puntos anómalos consecutivos para confirmar un incidente real
  4. Combina métricas correlacionadas para reducir falsos positivos: una tasa de errores alta más latencia alta más tráfico normal es más probablemente un incidente real que una tasa de errores alta por sí sola
  5. Las líneas base estacionales evitan falsas alertas por hora del día: construye líneas base separadas para cada hora y día de la semana para no alertar sobre patrones de tráfico diarios normales
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX