8 dias. 0 observações. 12 workflows GitHub Actions marcados como verde. Foi isso que descobri há seis horas, em 7 de abril de 2026, ao olhar meu dashboard de pesquisa em alexandrecaramaschi.com/research e ver overall_rate: 0, total_observations: 0, days_collecting: 0 em todas as quatro verticais.
O GitHub Actions me dizia que tinha rodado com sucesso desde 30 de março. Os commits estavam lá, datados, com mensagens automáticas perfeitas: data: daily collection 4 verticals 2026-04-07.
Eu tinha um workflow chamando python -m src.cli collect citation para 4 verticais (fintech, varejo, saúde, tecnologia), 4 LLMs (ChatGPT, Claude, Gemini, Perplexity), todo dia às 06:00 BRT.
A pasta output/ tinha checkpoints atualizados. O dashboard estava no ar. E não havia uma única linha de dado real desde 30 de março.
A tese contraintuitiva
Workflows verdes em CI mentem. Especialmente quando o seu código tem continue-on-error: true espalhado por todo lado e o seu único critério de sucesso é "o processo não exceptioned".
O caso que eu vou contar é uma combinação de três falhas que se reforçam: API keys rotacionadas externamente sem propagação ao repositório, retorno HTTP 401 silencioso porque o coletor capturava a exceção e seguia, e um workflow YAML que considerava "completou sem crash" como "rodou bem". O resultado é o pior tipo de bug de pipeline: o que mantém todos os indicadores verdes enquanto a base de dados envelhece.
O contexto: pesquisa empírica de 90 dias
Estou rodando um estudo longitudinal sobre como LLMs citam empresas brasileiras. O design tem 4 verticais, 69 entidades (61 reais + 8 fictícias para calibração de falso positivo), 4 modelos com versão pinada (gpt-4o-mini-2024-07-18, claude-haiku-4-5-20251001, sonar, gemini-2.5-pro) e ~288 observações por dia. O alvo eram 90 dias contínuos, ~25.920 observações, três papers planejados para ArXiv + SIGIR/WWW + Information Sciences Q1.
A coleta começou em 24 de março. Tudo funcionou no dia 1. Em 25 e 26, um SyntaxError em Python 3.11 do CI (válido em 3.12 do meu local) matou a coleta — incidente já documentado, fixado, post-mortem escrito.
Em 29 de março, tudo funcionando de novo: 256 observações reais, distribuição saudável entre os 4 LLMs.
Em 30 de março, alguma coisa quebrou.
A causa raiz
Em algum momento entre 29 e 30 de março, eu rotacionei as 5 chaves de API do meu workspace local — provavelmente durante uma auditoria FinOps que estava fazendo no orquestrador multi-LLM. Atualizei o .env do repositório principal. Fiz smoke test, validei que tudo respondia HTTP 200. Segui em frente.
O que eu não fiz: propagar as chaves novas para os GitHub Secrets do repositório papers. As keys lá ficaram datadas de 24 de março, ainda apontando para o conjunto antigo, agora inválido.
A partir de 30 de março, todo dia às 06:00 BRT, o workflow rodava. Cada chamada a OpenAI retornava HTTP 401 invalid_api_key. Cada chamada a Anthropic retornava HTTP 401 invalid x-api-key. Cada chamada a Perplexity, mesma coisa. O Gemini retornava HTTP 400 por outro motivo (estrutura de resposta do 2.5 Pro com thinking mode incompatível com o parser que eu tinha — outro bug que vou cobrir abaixo).
E o coletor continuava. Porque a função collect() capturava as exceções, logava no stderr, e retornava uma lista vazia. A função do CLI verificava if results: antes de inserir no banco — lista vazia significava simplesmente "nada para inserir, ok, próxima vertical". Sem exit code não-zero. Sem raise. Sem alerta.
O job finalize baixava o artifact papers-db-latest do dia anterior, rodava o sync_to_supabase.py que agregava (zero linhas → todos os KPIs zerados), atualizava o snapshot da tabela papers_dashboard_data no Supabase com total_observations: 0, overall_rate: 0, days_collecting: 0, fazia upload do mesmo artifact inalterado, commitava data/daily_*.csv (vazio), data/finops_checkpoint.json e docs/. E saía com exit code 0.
12 dias assim. Workflow status: completed/success. Banco real: 186 observações estagnadas em 24 de março. Dashboard live: zeros em todas as verticais.
Como descobri
Não foi um alerta. Era para ser. Não havia.
Foi uma pergunta. "A coleta está funcionando consistentemente para termos massa crítica em 90 dias?"
Cinco minutos depois, baixando os logs do último run via gh run view --log e filtrando por ERROR:
ERROR: [ChatGPT] HTTP 401: invalid_api_key
ERROR: [Claude] HTTP 401: invalid x-api-key
ERROR: [Gemini] HTTP 400: ...
ERROR: [Perplexity] HTTP 401: Invalid API key provided
Repetido em loop por todas as 18 queries de cada uma das 4 verticais. Mais de 200 linhas de erro. E o workflow no topo da página dizia success em verde.
A decisão de regredir
Eu tinha uma escolha: fazer backfill manual com data alterada para preservar a sequência (mas com timestamps todos do dia atual, contaminando análises temporais), ou aceitar que perdi 8 dias e reiniciar o contador.
Reiniciei. A integridade temporal de um estudo longitudinal vale mais que a vaidade de um número de "dias contínuos". 90 dias com timestamps reais é evidência. 90 dias com 8 deles inventados é fraude metodológica.
O dia 1 da nova janela é 8 de abril. Dia 90 será 6 de julho de 2026. ~256 observações por dia × 90 dias ≈ 23.000 observações totais com integridade temporal preservada.
Os 5 fixes que vão garantir que isso nunca mais aconteça
1. Fail-loud no comando de coleta
src/cli.py::collect_citation agora soma o total de citações coletadas em todas as verticais. Se for zero quando pelo menos uma vertical foi tentada, o comando levanta SystemExit(1):
if total_attempted > 0 and total_collected == 0:
console.print(
f"FAIL-LOUD: 0 citacoes em {total_attempted} verticais. "
f"Provavel causa: API keys invalidas/expiradas, rate limiting, "
f"ou erro de configuracao."
)
raise SystemExit(1)
Isso garante que o workflow falha de verdade quando 100% das chamadas dão erro. Sem || true por cima. Sem continue-on-error. O job termina vermelho.
2. Retry policy granular no coletor
src/collectors/base.py antes só tratava HTTP 429. Agora trata cinco categorias diferentes:
| Erro | Comportamento |
|---|---|
HTTP 401/403 |
Circuit break imediato. Não retenta. Loga "rotacionar key no GitHub Secrets". |
HTTP 429 |
Retry com backoff exponencial. Após max retries, circuit break. |
HTTP 5xx |
Retry com backoff exponencial. |
ConnectError, ReadTimeout, WriteTimeout
|
Retry com backoff. |
HTTP 4xx fatais (400, 404, 422) |
Log e segue para a próxima query. |
A separação importa: 401 não é transient, é configuração. Retry não resolve. O fix é rotacionar a chave. Logar isso explicitamente faz a falha aparecer no diagnóstico em vez de ficar enterrada em retries inúteis.
3. Health check de 14 dimensões com alerta WhatsApp + email
Criei scripts/health_check.py no estilo do geo-finops/health_check.py que já existe no meu ecossistema. O script roda 14 checks ponta a ponta:
-
papers.dbexiste - Schema com 21 tabelas obrigatórias
- As 4 API keys estão carregadas no ambiente
- Smoke test real das 4 keys (faz uma chamada mínima a cada provider)
- Pelo menos 200 observações nas últimas 24h
- Todas as 4 verticais coletaram nas últimas 24h
- Todos os 4 LLMs responderam nas últimas 24h
- Sem gap maior que 1 dia entre coletas (warning)
-
papers_dashboard_datano Supabase comtotal_observations > 0 - FinOps gasto < 90% do budget mensal
- Endpoint
/researchretornando HTTP 200 - Modelos pinados no banco (versões específicas)
-
raw_textpreservado para reprocessamento - Entidades fictícias presentes no coorte (calibração de falso positivo)
Exit code 1 se qualquer check crítico falha. Quando falha, send_alert() dispara dois canais em paralelo: WhatsApp Business API para +5562998141505 e email via Resend para caramaschiai@caramaschiai.io. O conteúdo da mensagem inclui o sumário das falhas, métricas relevantes e um runbook básico de recovery.
Smoke test rodado: whatsapp: OK. Mensagem real chegou no celular.
4. Health check como gating no daily-collect
O daily-collect.yml ganhou um step novo no fim do job finalize:
- name: Health check (gating)
run: python scripts/health_check.py --min-obs-per-day 200
Sem continue-on-error. Se o health check falha, o workflow falha. Se o workflow falha, o daily-collect-alert.yml (workflow separado que escuta workflow_run.failure) dispara WhatsApp + email.
Mais um workflow agendado (health-check-daily.yml) roda 4 horas depois — 13:00 UTC, camada redundante caso o daily-collect tenha falhado em algum aspecto que o gating não pegou. Defesa em profundidade.
5. FinOps tighter
Os budgets default eram folgados demais ($35/mês global) para o custo real observado (~$1/mês). Se algum bug fizesse queries explodirem por horas antes de eu notar, o estrago poderia ser de duas ordens de grandeza acima do que faria sentido pagar.
Apertei tudo com 5x de margem sobre o custo médio observado:
| Provider | Antes | Depois | Hard stop |
|---|---|---|---|
| openai | $10/mês | $3/mês | 95% |
| anthropic | $10/mês | $3/mês | 95% |
| $5/mês | $2/mês | 100% | |
| perplexity | $10/mês | $3/mês | 95% |
| groq | $5/mês | $1/mês | 100% |
| global | $35/mês | $10/mês | 95% |
Hard stop em 95% por provider significa que quando o gasto chega lá, o tracker bloqueia novas chamadas para aquele provider até o reset diário/mensal. Bill shock previne-se com cap, não com confiança.
Os bugs que descobri por acidente no caminho
Gemini 2.5 Pro thinking mode
Enquanto debugava a coleta, descobri que mesmo com keys novas o Gemini estava retornando dados vazios. O modelo gemini-2.5-pro usa thinking tokens internos antes de gerar output. Com max_output_tokens = 300, o thinking budget esgotava os tokens e a resposta voltava com candidates[0].content sem campo parts. O parser fazia data["candidates"][0]["content"]["parts"][0]["text"] e dava KeyError: 'parts'. Mas o KeyError virava um log warning e a função retornava None — outro erro silencioso.
Fix: 4x o max_output_tokens para modelos *-pro (compensa o thinking budget) + tratamento gracioso de respostas sem parts (trata como string vazia em vez de exceção).
Idempotência exige normalização determinística do schema chave
Esse vem de um bug irmão no meu pacote geo-finops (tracking unificado de LLMs do meu ecossistema). Quando dois callers gravavam a mesma call lógica em formatos diferentes — Python local com microsegundos, Next.js server com milissegundos — eles passavam pelo dedup como "linhas diferentes". A constraint UNIQUE bate na string literal do timestamp, não no instante semântico.
Fix: _normalize_timestamp() que faz datetime.fromisoformat(...).astimezone(timezone.utc).isoformat() antes de qualquer INSERT. Se você expõe um schema chave que inclui timestamp, normalize obrigatoriamente. A documentação do PostgreSQL não vai te lembrar disso.
O que eu aprendi (e estou levando para todos os outros pipelines)
Workflows verdes mentem. Reescrevendo: workflows verdes não significam pipelines saudáveis. Eles significam que o processo terminou. A diferença entre os dois custou-me 8 dias de coleta e quase comprometeu um estudo de 90 dias.
continue-on-error: true é dívida técnica disfarçada de resiliência. Use com extrema parcimônia, e nunca em steps que produzem dados. Steps de cleanup, sim. Steps de coleta, jamais.
Smoke test de keys ≠ check de "key existe no env". Verificar que OPENAI_API_KEY está setada não diz nada sobre se ela é válida. O check 4 do meu health check faz uma chamada mínima a cada provider — custo total ~$0.0001, valor inestimável.
Defesa em profundidade > checagem única. Health check no daily-collect (camada 1) + workflow separado 4h depois (camada 2) + alerta WhatsApp em qualquer falha (camada 3) + retry granular no coletor (camada 4) + budget tight com hard stop (camada 5). Se uma camada falha, a próxima pega.
Double check exige dados reais, não mocks. O bug do 409 Conflict no geo-finops (e o do timestamp não-normalizado) só apareceram quando rodei testes reais de fim a fim. Mocks teriam passado todos os checks. O caminho certo é: executar caller real, validar cada estágio do pipeline, re-executar para validar idempotência, cleanup pós-teste, adicionar regressão automatizada.
Backfill com timestamp alterado é fraude. Se você está construindo evidência longitudinal, prefira o reset honesto à sequência inflada. Nove dias perdidos doem. Nove dias inventados invalidam o paper inteiro.
Onde isso vai
A nova janela começa amanhã, 8 de abril. Daqui a 90 dias eu deveria ter ~23.000 observações reais, com integridade temporal, todas com raw_text preservado para reprocessamento, modelos pinados para reprodutibilidade, e calibração de falso positivo embutida via 8 entidades fictícias.
O dashboard ao vivo está em https://alexandrecaramaschi.com/research. O código (incluindo todos os fixes desta noite) está em https://github.com/alexandrebrt14-sys/papers. O health check é executável e auditável em scripts/health_check.py — qualquer pessoa que queira replicar a metodologia consegue rodar os 14 checks no próprio fork.
Se você está construindo um pipeline de coleta longitudinal e ainda não tem fail-loud em nenhum step, faça isso hoje. Não amanhã. A diferença entre descobrir o bug em uma hora e descobrir em 12 dias é a diferença entre um post como este e um paper morto.
Estou contando para chegar a 6 de julho com massa crítica. Aceito relatos de bugs parecidos — o meu post-mortem é seu também.
Alexandre Caramaschi — CEO da Brasil GEO, ex-CMO da Semantix (Nasdaq), cofundador da AI Brasil. Escreve sobre Generative Engine Optimization, pesquisa empírica em LLMs e infraestrutura de pipelines em https://alexandrecaramaschi.com.
Top comments (0)