Analisador de stack traces encadeadas em Python mostrando erro no terminal

Stack Trace Analyzer em Python: O Parser Que Reconstrói o Caminho Completo do Erro em Sistemas Multi-Thread (Sem Framework)

Você já passou pela experiência de um bug que só aparece em produção, às 3 da manhã, e quando você vai olhar o log, encontra uma stack trace que parece ter sido escrita por alguém com Parkinson? Linhas se entrelaçam, threads aparecem do nada, e o traceback final não aponta para lugar nenhum que faça sentido?

Bem-vindo ao clube dos que sofrem com stack traces encadeadas em sistemas multi-thread. Eu passei 3 semanas caçando um deadlock intermitente num sistema de processamento de filas. O bug aparecia 1 vez a cada 500 execuções. Quando finalmente encontrei a causa, percebi que o Python já tinha todas as ferramentas para diagnosticar isso — eu só não sabia usá-las juntas.

Neste artigo, vou te mostrar como construir um analisador de stack traces encadeadas em Python puro que reconstrói o caminho completo de um erro, mesmo quando múltiplas threads estão envolvidas. Sem Sentry, sem Bugsnag, sem depender de serviço externo. Apenas traceback, faulthandler, threading e um pouco de SQLite para persistência.

O Problema Real: Stack Traces Que Mentem

Quando você tem uma aplicação com múltiplas threads e uma exception é lançada, o Python faz o básico: captura o traceback da thread que falhou e imprime. O problema é que, em sistemas com threads interdependentes, a thread que falha raramente é a thread que causou o problema.

Imagine este cenário: uma thread produtora enfileira dados, uma thread consumidora processa, e uma thread de heartbeat monitora a saúde do sistema. O heartbeat detecta timeout, tenta fazer cleanup, e explode com um RuntimeError: dictionary changed size during iteration. O traceback aponta para a thread de heartbeat. Mas o bug real? Está na thread produtora, que modificou o dicionário sem lock.

O traceback padrão te deixa na mão. Você precisa de algo que capture o estado de todas as threads no momento do crash.

A Anatomia de um Stack Trace Completo

Antes de construir a solução, vamos entender o que precisamos capturar:

  • Thread ID e nome — identificação única de cada thread ativa
  • Frame stack — a pilha de chamadas de cada thread no momento do crash
  • Locals de cada frame — variáveis locais que podem revelar o estado do bug
  • Relações de dependência — qual thread espera por qual, quem criou quem
  • Timestamp preciso — para correlacionar com outros logs

O Python tem módulos para cada um desses pontos. O truque é costurá-los juntos.

Módulo 1: faulthandler — O Detector de Crashes Fatais

O faulthandler é subestimado pela maioria dos devs Python. Ele captura crashes que o sys.excepthook não alcança — segmentation faults, stack overflows, sinais POSIX como SIGSEGV e SIGABRT. E mais importante: ele pode dumpar o traceback de todas as threads.

import faulthandler
import sys
import os

def setup_faulthandler(log_path="crash_dump.log"):
    """Ativa o faulthandler para capturar crashes fatais."""
    log_file = open(log_path, "w")
    faulthandler.enable(file=log_file, all_threads=True)
    # Captura SIGUSR1 para dump manual (kill -USR1 pid)
    faulthandler.register(signal.SIGUSR1, file=log_file, all_threads=True)
    return log_file

# Ativa no startup da aplicação
log_file = setup_faulthandler()

O detalhe crucial: all_threads=True é o que diferencia um dump útil de um inútil. Sem isso, você só vê a thread que recebeu o sinal.

Módulo 2: sys._current_frames() — O Raio-X das Threads

Essa é a arma secreta que quase ninguém conhece. sys._current_frames() retorna um dicionário mapeando thread ID → frame atual de todas as threads ativas. É exatamente o que precisamos para reconstruir o estado global no momento do crash.

import sys
import threading
import traceback

def capture_all_thread_frames():
    """Captura o frame stack de todas as threads ativas."""
    frames = sys._current_frames()
    thread_map = {t.ident: t for t in threading.enumerate()}
    
    snapshot = {}
    for thread_id, frame in frames.items():
        thread = thread_map.get(thread_id)
        thread_name = thread.name if thread else f"Unknown-{thread_id}"
        
        # Extrai a stack completa do frame
        stack = traceback.extract_stack(frame)
        
        # Captura locals do frame mais recente
        current_locals = {}
        if frame and frame.f_locals:
            current_locals = {
                k: safe_repr(v) 
                for k, v in frame.f_locals.items()
                if not k.startswith('__')
            }
        
        snapshot[thread_name] = {
            "thread_id": thread_id,
            "alive": thread.is_alive() if thread else False,
            "daemon": thread.daemon if thread else None,
            "stack": [(f.filename, f.lineno, f.name, f.line) for f in stack],
            "locals": current_locals
        }
    
    return snapshot

def safe_repr(obj, max_len=200):
    """repr seguro que não explode com objetos complexos."""
    try:
        r = repr(obj)
        return r[:max_len] + "..." if len(r) > max_len else r
    except Exception:
        return f"<unrepr {type(obj).__name__}>"

Repare no safe_repr(). Parece paranoia, mas quando você está capturando locals no momento de um crash, qualquer coisa pode dar errado. Objetos com __repr__ quebrado, referências circulares, generators exauridos — todos explodem se você não proteger.

Terminal mostrando stack trace multi-thread em Python

Módulo 3: O Correlacionador — Ligando Threads ao Erro

Aqui é onde a mágica acontece. Ter os frames de todas as threads é ótimo, mas você precisa correlacionar qual thread causou o problema e quais foram afetadas.

import json
import sqlite3
from datetime import datetime
from pathlib import Path

class StackTraceAnalyzer:
    def __init__(self, db_path="trace_history.db"):
        self.db_path = db_path
        self._init_db()
    
    def _init_db(self):
        with sqlite3.connect(self.db_path) as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS crash_events (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    timestamp TEXT NOT NULL,
                    error_type TEXT,
                    error_message TEXT,
                    originating_thread TEXT,
                    full_snapshot TEXT,
                    correlation_hash TEXT
                )
            """)
            conn.execute("""
                CREATE TABLE IF NOT EXISTS crash_patterns (
                    pattern_hash TEXT PRIMARY KEY,
                    first_seen TEXT,
                    last_seen TEXT,
                    occurrence_count INTEGER DEFAULT 1,
                    sample_snapshot TEXT
                )
            """)
    
    def capture_crash(self, exception=None):
        """Captura um evento de crash completo."""
        snapshot = capture_all_thread_frames()
        current_thread = threading.current_thread().name
        
        error_type = type(exception).__name__ if exception else "Unknown"
        error_msg = str(exception) if exception else "No exception"
        
        # Gera hash para correlação de padrões
        pattern_key = self._compute_pattern_hash(snapshot, error_type)
        
        event = {
            "timestamp": datetime.utcnow().isoformat(),
            "error_type": error_type,
            "error_message": error_msg,
            "originating_thread": current_thread,
            "snapshot": snapshot,
            "pattern_hash": pattern_key
        }
        
        self._persist(event)
        return event
    
    def _compute_pattern_hash(self, snapshot, error_type):
        """Hash baseado nos file:line da stack, não em valores voláteis."""
        import hashlib
        stack_signature = []
        for thread_name, data in sorted(snapshot.items()):
            for filename, lineno, funcname, _ in data["stack"][-3:]:
                stack_signature.append(f"{filename}:{lineno}")
        
        raw = error_type + "|" + "|".join(stack_signature)
        return hashlib.sha256(raw.encode()).hexdigest()[:16]
    
    def _persist(self, event):
        with sqlite3.connect(self.db_path) as conn:
            conn.execute(
                "INSERT INTO crash_events (timestamp, error_type, error_message, "
                "originating_thread, full_snapshot, correlation_hash) "
                "VALUES (?, ?, ?, ?, ?, ?)",
                (event["timestamp"], event["error_type"], event["error_message"],
                 event["originating_thread"], json.dumps(event["snapshot"], default=str),
                 event["pattern_hash"])
            )
            # Atualiza padrões
            conn.execute("""
                INSERT INTO crash_patterns (pattern_hash, first_seen, last_seen, 
                    occurrence_count, sample_snapshot)
                VALUES (?, ?, ?, 1, ?)
                ON CONFLICT(pattern_hash) DO UPDATE SET
                    last_seen = excluded.last_seen,
                    occurrence_count = occurrence_count + 1
            """, (event["pattern_hash"], event["timestamp"], event["timestamp"],
                  json.dumps(event["snapshot"], default=str)))
    
    def find_recurring_patterns(self, min_occurrences=2):
        """Encontra padrões de crash recorrentes."""
        with sqlite3.connect(self.db_path) as conn:
            rows = conn.execute(
                "SELECT pattern_hash, occurrence_count, first_seen, last_seen "
                "FROM crash_patterns WHERE occurrence_count >= ? "
                "ORDER BY occurrence_count DESC",
                (min_occurrences,)
            ).fetchall()
        return rows

Integrando com excepthook e threading.excepthook

O Python 3.8+ trouxe o threading.excepthook, que captura exceptions não tratadas em threads individuais. Combinado com sys.excepthook, você cobre todos os cenários:

analyzer = StackTraceAnalyzer()

def global_exception_handler(exc_type, exc_value, exc_tb):
    """Handler para exceções não tratadas na thread principal."""
    event = analyzer.capture_crash(exc_value)
    # Loga o snapshot completo
    print(f"\n{'='*60}")
    print(f"CRASH CAPTURADO — Pattern: {event['pattern_hash']}")
    print(f"Thread de origem: {event['originating_thread']}")
    print(f"Threads ativas: {len(event['snapshot'])}")
    print(f"{'='*60}\n")
    
    # Chama o handler padrão também
    sys.__excepthook__(exc_type, exc_value, exc_tb)

def thread_exception_handler(args):
    """Handler para exceções não tratadas em threads."""
    event = analyzer.capture_crash(args.exc_value)
    print(f"THREAD CRASH: {args.thread.name} — Pattern: {event['pattern_hash']}")

# Instala os handlers
sys.excepthook = global_exception_handler
threading.excepthook = thread_exception_handler

🔧 Perrengue Real: Eu implementei o threading.excepthook e achei que estava seguro. Duas semanas depois, descobri que exceptions capturadas por try/except genéricos dentro de threads não passam pelo excepthook — óbvio, né? Mas eu não tinha pensado nisso. A solução foi adicionar um decorator @catch_and_analyze em todas as funções de entrada de threads críticas. Não é elegante, mas captura tudo.

Detectando Race Conditions com Frame Diffing

Aqui vai a parte mais interessante: usar snapshots sequenciais para detectar race conditions em tempo real, antes mesmo do crash.

import time
from collections import defaultdict

class RaceConditionDetector:
    def __init__(self, interval_ms=100):
        self.interval = interval_ms / 1000
        self.snapshots = []
        self.max_snapshots = 50
        self.shared_state_access = defaultdict(list)
    
    def take_snapshot(self):
        """Captura um snapshot e detecta acesso concorrente a shared state."""
        snapshot = capture_all_thread_frames()
        self.snapshots.append({
            "time": time.monotonic(),
            "data": snapshot
        })
        
        if len(self.snapshots) > self.max_snapshots:
            self.snapshots.pop(0)
        
        # Detecta threads acessando as mesmas funções/arquivos
        self._detect_concurrent_access(snapshot)
    
    def _detect_concurrent_access(self, snapshot):
        """Identifica funções sendo executadas simultaneamente por múltiplas threads."""
        active_functions = defaultdict(list)
        
        for thread_name, data in snapshot.items():
            if data["stack"]:
                # Pega os últimos 3 frames
                for filename, lineno, funcname, _ in data["stack"][-3:]:
                    key = f"{filename}:{funcname}"
                    active_functions[key].append(thread_name)
        
        # Funções acessadas por 2+ threads simultaneamente
        for key, threads in active_functions.items():
            if len(threads) >= 2:
                self.shared_state_access[key].append({
                    "time": time.monotonic(),
                    "threads": threads
                })
    
    def get_suspicious_patterns(self):
        """Retorna funções com acesso concorrente frequente."""
        suspicious = []
        for key, accesses in self.shared_state_access.items():
            if len(accesses) >= 3:
                suspicious.append({
                    "function": key,
                    "concurrent_accesses": len(accesses),
                    "threads_involved": list(set(
                        t for a in accesses for t in a["threads"]
                    ))
                })
        return suspicious
Codigo Python com traceback e faulthandler

O Relatório de Diagnóstico — Transformando Dados em Ação

Ter os dados é metade do trabalho. A outra metade é transformá-los em algo legível que te ajude a agir:

def generate_diagnostic_report(event, analyzer):
    """Gera um relatório legível do crash."""
    lines = []
    lines.append(f"# Crash Report — {event['timestamp']}")
    lines.append(f"\n**Erro:** {event['error_type']}: {event['error_message']}")
    lines.append(f"**Thread de origem:** {event['originating_thread']}")
    lines.append(f"**Pattern hash:** {event['pattern_hash']}")
    
    # Verifica se é um padrão recorrente
    patterns = analyzer.find_recurring_patterns(min_occurrences=2)
    for p in patterns:
        if p[0] == event['pattern_hash']:
            lines.append(f"\n⚠️  **PADRÃO RECORRENTE:** {p[1]} ocorrências desde {p[2]}")
    
    lines.append(f"\n## Snapshot de Threads ({len(event['snapshot'])} ativas)")
    
    for thread_name, data in sorted(event['snapshot'].items()):
        status = "🟢" if data["alive"] else "🔴"
        lines.append(f"\n### {status} {thread_name} (ID: {data['thread_id']})")
        
        if data["stack"]:
            lines.append("```")
            for filename, lineno, funcname, line in data["stack"][-5:]:
                short_file = filename.split("/")[-1]
                lines.append(f"  {short_file}:{lineno} in {funcname}()")
                if line:
                    lines.append(f"    {line}")
            lines.append("```")
        
        if data["locals"]:
            lines.append("\n**Locals:**")
            for k, v in list(data["locals"].items())[:10]:
                lines.append(f"- `{k}` = {v}")
    
    return "\n".join(lines)

Casos de Uso Práticos

1. Debug de Deadlocks

Quando duas threads esperam uma pela outra, o sistema trava sem exception. Mas com snapshots periódicos, você vê ambas as threads paradas no mesmo ponto:

def detect_deadlocks(analyzer, threshold_seconds=10):
    """Detecta threads paradas no mesmo frame por muito tempo."""
    # Compara snapshots sequenciais
    if len(analyzer.snapshots) < 2:
        return []
    
    latest = analyzer.snapshots[-1]["data"]
    oldest = analyzer.snapshots[-min(10, len(analyzer.snapshots))]["data"]
    time_diff = analyzer.snapshots[-1]["time"] - analyzer.snapshots[-min(10, len(analyzer.snapshots))]["time"]
    
    if time_diff < threshold_seconds:
        return []
    
    stuck_threads = []
    for thread_name in latest:
        if thread_name in oldest:
            if latest[thread_name]["stack"][-1] == oldest[thread_name]["stack"][-1]:
                stuck_threads.append(thread_name)
    
    return stuck_threads

2. Detecção de Lock Contention

Threads que passam muito tempo em threading.Lock.acquire() indicam contention. O snapshot mostra isso claramente quando você vê múltiplas threads paradas em acquire().

3. Correlação com Métricas de Performance

Salvando o pattern_hash junto com métricas de CPU/memória, você consegue responder perguntas como: "Os crashes com hash a3f7b2 acontecem quando a CPU está acima de 80%?"

Deploy: Colocando em Produção Sem Medo

Aqui vai a configuração mínima para ativar tudo no startup:

import signal

def bootstrap_trace_analyzer():
    """Ativa o analisador de stack traces no startup."""
    analyzer = StackTraceAnalyzer(db_path="/var/log/app/traces.db")
    detector = RaceConditionDetector(interval_ms=200)
    
    # Handlers de exceção
    sys.excepthook = lambda *args: (
        analyzer.capture_crash(args[1]),
        sys.__excepthook__(*args)
    )
    threading.excepthook = lambda args: (
        analyzer.capture_crash(args.exc_value)
    )
    
    # faulthandler para crashes fatais
    faulthandler.enable(all_threads=True)
    
    # Snapshot periódico em background
    def periodic_snapshot():
        while True:
            detector.take_snapshot()
            time.sleep(detector.interval)
    
    monitor = threading.Thread(
        target=periodic_snapshot, 
        daemon=True, 
        name="trace-monitor"
    )
    monitor.start()
    
    # SIGUSR1 para dump manual
    def dump_handler(signum, frame):
        event = analyzer.capture_crash()
        report = generate_diagnostic_report(event, analyzer)
        print(report)
    
    signal.signal(signal.SIGUSR1, dump_handler)
    
    return analyzer, detector

# No main da aplicação:
analyzer, detector = bootstrap_trace_analyzer()

Limitações e Armadilhas

Vou ser honesto sobre o que esse sistema não faz:

  • Não substitui um profiler — o overhead de capturar locals em cada snapshot é real. Use em produção com intervalo generoso (200ms+).
  • Locals podem conter dados sensíveis — o safe_repr captura tudo, inclusive senhas que estão em variáveis locais. Filtre campos sensíveis.
  • Não detecta race conditions em C extensions — se o bug está no nível C do Python (ou numa lib como numpy), o frame Python não vai mostrar.
  • SQLite pode ser gargalo — em crashes frequentes, o writes concorrentes no SQLite podem travar. Considere um WAL mode ou queue.

Conclusão: Pare de Adivinhar, Comece a Capturar

Race conditions e bugs intermitentes são o pesadelo de todo desenvolvedor porque parecem impossíveis de reproduzir. Mas a verdade é que, na maioria das vezes, o Python já estava te dando todas as ferramentas para diagnosticá-los — você só precisava costurá-las juntas.

O stack trace analyzer não é um framework. Não tem pip install. Não tem dashboard bonitinho. É um conjunto de funções Python que, quando combinadas, te dão uma visibilidade que nenhum serviço pago oferece de forma tão granular.

Da próxima vez que um bug intermitente aparecer em produção às 3 da manhã, você não vai precisar adivinhar. Vai dar kill -USR1 <pid> e ter a resposta em segundos.

Agora me diz: qual é o bug intermitente que está tirando seu sono agora? Aquele que você nunca consegue reproduzir no ambiente de desenvolvimento? Comenta aí que eu posso montar um analisador específico para o seu caso no próximo post.

Links úteis:

Posts Similares