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.

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çaStatisticsErrorquando 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)

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.
