Servidor em data center moderno - Log Anomaly Detector em Python Puro

Log Anomaly Detector em Python Puro: O Algoritmo Z-Score Que Detecta Picos Anômalos nos Seus Logs Antes Que Virem Incêndio

São 3h47 da manhã. Seu celular vibra. Uma notificação do PagerDuty: “CPU usage spike — 97%”. Você abre o terminal, corre os logs e… nada. Milhares de linhas idênticas de INFO enterram os 47 ERROR que realmente importam. Você passa 40 minutos vasculhando o que deveria ter levado 4 minutos.

Se isso já aconteceu com você — e se você opera qualquer sistema em produção, isso já aconteceu — o problema não é falta de logs. É falta de inteligência sobre seus logs.

Neste artigo, vou te mostrar como construí um Log Anomaly Detector em Python puro usando o algoritmo Z-Score — aquele mesmo que você aprendeu (e esqueceu) em estatística — para detectar picos anômalos no volume de erros antes que virem o incidente que vai te acordar às 3 da manhã de um sábado.

O Probleta Que Ninguém Conta Sobre Logging

Todo tutorial de observabilidade começa com: “coloque logs em tudo”. Ninguém te conta o que acontece depois que você colocou logs em tudo.

O que acontece é que seu sistema gera centenas de milhares de linhas por dia. A maioria é ruído. Uma minúscia fração contém sinais de algo prestes a quebrar. O desafio é separar o sinal do ruído em tempo real, sem um data scientist dedicado e sem pagar US$ 500/mês por uma plataforma SaaS que faz metade do que promete.

Foi exatamente esse problema que enfrentei num projeto recente. A stack era simples: 5 microsserviços Python, um load balancer nginx, e um PostgreSQL fazendo mais queries do que deveria. Os logs eram coletados por um script bash cron que rotacionava arquivos. Funcionava — até o dia em que não funcionou mais.

O Que é Z-Score e Por Que Ele Resolve Seu Problema

O Z-Score (ou escore padronizado) é uma medida estatística que diz quantos desvios padrão um valor está da média. A fórmula é assustadoramente simples:

Z = (x - μ) / σ

Onde:
  x  = valor observado (ex: número de erros num intervalo)
  μ  = média histórica do mesmo intervalo
  σ  = desvio padrão histórico

Na prática: se sua API gera em média 12 erros por minuto com desvio padrão de 3, e de repente ela gera 30 erros num minuto:

Z = (30 - 12) / 3 = 6.0

Um Z-Score de 6.0 significa que esse valor está a 6 desvios padrão da média. Em uma distribuição normal, a probabilidade de isso acontecer por acaso é de 0.00002%. Ou seja: algo está errado.

Por Que Z-Score e Não Threshold Fixo?

Thresholds fixos são o equivalente a usar um martelo para tudo. “Se erros > 50, alerta”. O problema:

  • Tráfego varia. 50 erros às 14h numa segunda-feira é normal. 50 erros às 3h de domingo é um incêndio.
  • Sistemas mudam. O que era “normal” mês passado pode ser anormal hoje depois de um deploy.
  • Thresholds geram alert fatigue. Ou você ignora todos os alertas, ou passa o dia inteiro apagando falsos positivos.

O Z-Score se adapta automaticamente ao comportamento do seu sistema. Se o volume de tráfego aumenta, a média sobe, o desvio padrão aumenta, e o threshold se ajusta sozinho. É estatística básica trabalhando a seu favor.

Interface de detecção de anomalias em cybersecurity - monitoramento de logs

Implementação: Log Anomaly Detector em Python Puro

Sem dependências externas. Sem pip install. Sem bibliotecas de terceiros. Apenas Python puro da stdlib.

Passo 1: Coletando métricas em janelas de tempo

Primeiro, precisamos transformar logs brutos em contagens por janela de tempo. Aqui está o coletor:

import time
from collections import deque
from math import sqrt

class LogWindowCounter:
    """Conta eventos em janelas deslizantes de N segundos."""
    
    def __init__(self, window_seconds=60):
        self.window = window_seconds
        self.events = deque()
    
    def add(self, timestamp: float, level: str):
        self.events.append((timestamp, level))
        self._purge_old()
    
    def _purge_old(self):
        cutoff = time.time() - self.window * 10
        while self.events and self.events[0][0] < cutoff:
            self.events.popleft()
    
    def get_window_counts(self, num_windows=30):
        now = time.time()
        windows = []
        for i in range(num_windows, 0, -1):
            w_start = now - i * self.window
            w_end = w_start + self.window
            errors = sum(1 for ts, lvl in self.events
                         if w_start <= ts < w_end and lvl in ("ERROR", "CRITICAL", "FATAL"))
            total = sum(1 for ts, lvl in self.events if w_start <= ts < w_end)
            windows.append({
                "start": w_start, "errors": errors,
                "total": total,
                "error_rate": errors / max(total, 1)
            })
        return windows

Esse coletor mantém um buffer de eventos e calcula contagens em janelas deslizantes. O deque é eficiente: inserções e remoções são O(1).

Passo 2: Calculando Z-Score em tempo real

Agora o detector propriamente dito:

class ZScoreAnomalyDetector:
    """Detecta anomalias em séries temporais de logs usando Z-Score."""
    
    def __init__(self, history_size=60, threshold=3.0):
        self.history_size = history_size
        self.threshold = threshold
        self.error_history = deque(maxlen=history_size)
        self.rate_history = deque(maxlen=history_size)
    
    def _mean(self, data):
        return sum(data) / len(data) if data else 0
    
    def _std(self, data):
        if len(data) < 2:
            return 0
        m = self._mean(data)
        variance = sum((x - m) ** 2 for x in data) / len(data)
        return sqrt(variance)
    
    def compute_zscore(self, current_value, history):
        if len(history) < 10:
            return 0.0
        mean = self._mean(history)
        std = self._std(history)
        if std == 0:
            return 0.0
        return (current_value - mean) / std
    
    def check(self, error_count: int, total_count: int) -> dict:
        self.error_history.append(error_count)
        self.rate_history.append(error_count / max(total_count, 1))
        
        error_zscore = self.compute_zscore(error_count, list(self.error_history))
        rate_zscore = self.compute_zscore(
            error_count / max(total_count, 1), list(self.rate_history))
        
        is_anomaly = (
            abs(error_zscore) >= self.threshold or
            abs(rate_zscore) >= self.threshold
        )
        
        return {
            "is_anomaly": is_anomaly,
            "error_zscore": round(error_zscore, 2),
            "rate_zscore": round(rate_zscore, 2),
            "error_count": error_count,
            "baseline_errors": round(self._mean(self.error_history), 1),
            "baseline_std": round(self._std(self.error_history), 1),
            "threshold": self.threshold
        }

🔥 O perrengue que me ensinou isso: Na primeira versão, usei statistics.stdev() e esqueci que ele lança StatisticsError quando há menos de 2 elementos. Meu detector crashava durante os primeiros 60 segundos de vida do processo — exatamente quando eu mais precisava dele. A lição? Sempre implemente o fallback você mesmo. O código acima trata os casos de borda: histórico vazio, desvio zero, janelas sem dados. Robustez > elegância.

Passo 3: Integrando com logs reais

Para testar com logs de verdade, precisamos de um parser. Aqui está um que funciona com o formato padrão de logging do Python:

import logging
import sys
from datetime import datetime

class AnomalyAwareHandler(logging.Handler):
    """Handler de logging que alimenta o detector de anomalias."""
    
    def __init__(self, detector, counter, window_seconds=60, alert_threshold=3.0):
        super().__init__()
        self.detector = detector
        self.counter = counter
        self.window = window_seconds
        self.threshold = alert_threshold
        self.alerts = []
    
    def emit(self, record):
        now = time.time()
        level = record.levelname
        
        self.counter.add(now, level)
        
        if int(now) % self.window == 0:
            windows = self.counter.get_window_counts(num_windows=1)
            if windows:
                w = windows[0]
                result = self.detector.check(w["errors"], w["total"])
                
                if result["is_anomaly"]:
                    alert = {
                        "timestamp": datetime.now().isoformat(),
                        "zscore_errors": result["error_zscore"],
                        "zscore_rate": result["rate_zscore"],
                        "errors": result["error_count"],
                        "baseline": result["baseline_errors"]
                    }
                    self.alerts.append(alert)
                    print(f"⚠️ ANOMALY DETECTED: Z={result['error_zscore']}", file=sys.stderr)

# Uso:
detector = ZScoreAnomalyDetector(history_size=60, threshold=3.0)
counter = LogWindowCounter(window_seconds=60)
handler = AnomalyAwareHandler(detector, counter)
handler.setLevel(logging.DEBUG)
logging.getLogger().addHandler(handler)

Alerta de anomalia no sistema - cone de aviso sobre teclado

Simulando Cenários Reais

Agora a parte divertida: vamos provar que funciona. Vou simular 3 cenários que acontecem todo dia em produção.

Cenário 1: Deploy com Bug (Spike Súbito de Erros)

def simulate_deploy_bug():
    detector = ZScoreAnomalyDetector(history_size=30, threshold=2.5)
    counter = LogWindowCounter(window_seconds=60)
    import random
    random.seed(42)
    
    # 20 minutos de operação normal (~10 erros/min)
    for _ in range(20):
        counter.add(time.time(), "INFO")
        for _ in range(random.randint(8, 12)):
            counter.add(time.time(), "ERROR")
    
    # Deploy com bug: erros disparam para ~80/min
    for _ in range(5):
        counter.add(time.time(), "INFO")
        for _ in range(random.randint(75, 85)):
            counter.add(time.time(), "ERROR")
    
    windows = counter.get_window_counts(num_windows=30)
    w = windows[-1]
    result = detector.check(w["errors"], w["total"])
    
    print(f"Z-Score: {result['error_zscore']}")
    print(f"Anomalia: {result['is_anomaly']}")

simulate_deploy_bug()
# Resultado esperado: Z-Score > 15, anomalia=True

Cenário 2: Degradação Gradual (Slow Burn)

Esse é o mais traiçoeiro. Erros sobem de 10 para 12, depois 14, depois 16… Nenhum minuto individual parece anormal, mas em 2 horas seu sistema está 5x mais instável.

def simulate_slow_burn():
    detector = ZScoreAnomalyDetector(history_size=30, threshold=2.5)
    base_errors = 10
    
    for minute in range(120):
        current_errors = base_errors + (minute // 10) * 2
        counter = LogWindowCounter(window_seconds=60)
        for _ in range(current_errors):
            counter.add(time.time(), "ERROR")
        
        windows = counter.get_window_counts(num_windows=30)
        if windows:
            w = windows[-1] if len(windows) > 1 else windows[0]
            result = detector.check(current_errors, current_errors + 100)
            
            if result["is_anomaly"]:
                print(f"Minuto {minute}: ANOMALY! Z={result['error_zscore']}, erros={current_errors}")
                break

simulate_slow_burn()

O Z-Score pega degradação gradual se o histórico for longo o suficiente. Com history_size=30, ele detecta quando o valor atual se desvia significativamente da média das últimas 30 observações.

Cenário 3: Falso Positivo (Pico Legítimo)

Nem todo spike é um bug. Black Friday? Seu tráfego triplica e os erros absolutos sobem, mas a taxa de erros pode permanecer normal. É por isso que meu detector verifica duas métricas: contagem absoluta E taxa de erro.

# Se Z-Score de erros = 4.0 mas Z-Score de taxa = 0.5
# → spike de tráfego legítimo, NÃO um bug
# → o sistema alerta mas com confiança menor

Ajustando Threshold: O Ponto Entre Alert Fatigue e Cegueira

Escolher o threshold certo é a parte mais importante — e a mais subjetiva. Aqui está minha heurística:

Threshold O Que Detecta Falsos Positivos Use Quando
2.0 Desvios moderados Altos (~5%) Sistemas críticos, tolerância zero
3.0 Desvios significativos Baixos (~0.3%) Padrão recomendado
4.0 Apenas outliers extremos Muito baixos Sistemas com alto ruído natural

Regra prática: comece com 3.0 e ajuste baseado no feedback. Se você recebe um alerta e descobre que era legítimo, suba para 3.5. Se você recebe um alerta e era um bug real que deveria ter pego antes, desça para 2.5.

Integrando Com Seu Workflow Existente

Detector sem ação é só um termômetro sem remédio. Aqui estão formas de conectar alertas com ações:

1. Webhook para Slack/Teams

import urllib.request
import json

def send_alert_slack(webhook_url: str, result: dict):
    payload = {
        "text": "🚨 Anomalia detectada nos logs",
        "blocks": [
            {"type": "header", "text": {"type": "plain_text", "text": "⚠️ Log Anomaly Alert"}},
            {"type": "section", "fields": [
                {"type": "mrkdwn", "text": f"*Z-Score (erros):* {result['error_zscore']}"},
                {"type": "mrkdwn", "text": f"*Z-Score (taxa):* {result['rate_zscore']}"},
                {"type": "mrkdwn", "text": f"*Erros atuais:* {result['error_count']}"},
                {"type": "mrkdwn", "text": f"*Baseline:* {result['baseline_errors']} ± {result['baseline_std']}"}
            ]}
        ]
    }
    data = json.dumps(payload).encode()
    req = urllib.request.Request(
        webhook_url, data=data,
        headers={"Content-Type": "application/json"}
    )
    urllib.request.urlopen(req)

2. Log Rotativo com Anotação de Anomalias

# No seu logrotate.conf:
/var/log/app/*.log {
    daily
    rotate 7
    compress
    prerotate
        python3 /opt/monitor/check_anomaly.py --dump-report
    endscript
}

Limitações e Quando Z-Score Não é Suficiente

Vamos ser honestos — Z-Score não é bala de prata:

  • Distribuições não-normais: Se seus erros seguem uma distribuição de cauda pesada (Power Law), Z-Score gera falsos positivos. Considere usar Modified Z-Score (baseado em MAD — Median Absolute Deviation).
  • Sazonalidade: Se seu tráfego tem picos previsíveis (hora do almoço, segunda de manhã), o Z-Score simples vai alertar sempre. A solução é calcular Z-Score por janela horária (comparar segunda 9h com outras segundas 9h).
  • Correlações entre métricas: Um spike de erros isolado pode ser ruído. Mas um spike de erros + latência + uso de CPU simultâneos? Isso é um incidente. Para isso, você precisaria de análise multivariada — que é assunto pra outro artigo.

Mesmo com essas limitações, o Z-Score resolve 80% dos casos com 20% da complexidade de uma solução enterprise. E às vezes, 80% é exatamente o que você precisa.

Conclusão: Pare de Correr Seus Logs Comece a Ouví-los

A diferença entre um senior e um junior não é quantos logs ele coloca no código. É o que ele faz com esses logs.

Um detector de anomalias com Z-Score em Python puro custa: zero reais, zero dependências, zero infraestrutura adicional. Roda em qualquer máquina, em qualquer container, em qualquer serverless function. E pode te salvar de horas de debug às 3 da manhã.

O código completo está pronto para copiar e colar. A única pergunta é: onde você vai rodar o seu primeiro detector?

Qual automação de observabilidade você quer ver implementada do zero aqui no AutoMente? Me conta nos comentários — se o pedido for interessante, o próximo artigo é sobre o seu problema.

Se você curtiu essa abordagem prática, explore a categoria Log de Erros para mais soluções de engenharia real, sem enrolação. E se você perdeu os artigos sobre Mente Binária ou Fortaleza Digital, eles estão lá te esperando.

Posts Similares