Cruzei o primeiro 5xx com o primeiro alerta em 11 incidentes: mediana de 1h52 até alguém saber
Recebi acesso de leitura ao log HTTP e à base de tickets de um serviço em Node com TypeScript e fui medir uma coisa só: quanto tempo passa entre o primeiro erro 500 chegar no usuário e alguém de dentro da empresa saber que ele existe.
A janela foi de algo perto de 90 dias. O painel de monitoramento ficou verde o período inteiro, e o /health respondeu 200 em todos os 11 incidentes que eu achei.
Como achei os incidentes sem ter campo de incidente
Não existia incidente_id em lugar nenhum. O que existia era uma tabela de log de requisição com rota, status e timestamp. Então eu defini surto por bucket de 5 minutos com volume mínimo, para não contar rota morta que deu dois erros no mês:
with buckets as (
select
rota,
to_timestamp(floor(extract(epoch from ocorrido_em) / 300) * 300) as bucket,
count(*) filter (where status >= 500) as erros,
count(*) as total
from log_http
where ocorrido_em >= now() - interval '90 days'
group by 1, 2
)
select rota, bucket, erros, total,
round(erros::numeric / total, 3) as taxa
from buckets
where total >= 20
and erros::numeric / total > 0.05
order by bucket;
Depois juntei cada surto com a primeira coisa que alguém de dentro registrou, seja alerta disparado, seja ticket aberto pelo cliente:
select
s.rota,
s.inicio,
min(a.disparado_em) as primeiro_alerta,
min(t.criado_em) as primeiro_ticket,
least(min(a.disparado_em), min(t.criado_em)) - s.inicio as deteccao
from surtos s
left join alerta a
on a.disparado_em between s.inicio and s.inicio + interval '12 hours'
left join ticket t
on t.criado_em between s.inicio and s.inicio + interval '12 hours'
group by 1, 2
order by s.inicio;
Saída, com as rotas trocadas e três linhas de amostra:
rota | inicio | primeiro_alerta | primeiro_ticket | deteccao
-------------------------+------------------+------------------+------------------+----------
/checkout/confirm | 2026-07-02 14:35 | 2026-07-02 21:15 | 2026-07-02 16:27 | 01:52:00
/checkout/confirm | 2026-07-19 09:05 | | 2026-07-19 10:58 | 01:53:00
/pedidos/:id/pagamento | 2026-08-04 11:20 | 2026-08-04 13:02 | 2026-08-04 12:41 | 01:21:00
(11 linhas)
mediana(deteccao) = 01:52:00 | pior caso = 06:40:00
Em 7 dos 11 surtos o primeiro registro foi o ticket do cliente. O alerta, quando veio, foi de CPU acima de 80% na instância, que é o retry do app mobile batendo em cima do erro. O alerta media a consequência, não a falha.
O que o /health estava provando
Essa é a linha inteira do endpoint que mantinha o painel verde:
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok' }));
$ curl -s -o /dev/null -w '%{http_code}\n' https://api.exemplo/health
200
Ele não toca banco, não toca fila, não toca o gateway de pagamento. Prova que o processo subiu e que o event loop ainda responde. No incidente de 02/07 o pool do Postgres estava estourado, /checkout/confirm devolvia 500 em metade das chamadas, e o 200 do health continuava lá, honesto no que mede e inútil para o que estava quebrando.
O que eu troquei
Primeiro, separei liveness de readiness e fiz o readiness checar dependência com timeout curto:
const DEPS = {
db: () => pool.query('select 1'),
fila: () => redis.ping(),
psp: () => fetch(`${PSP_URL}/ping`, { signal: AbortSignal.timeout(800) }),
};
app.get('/health/ready', async (_req, res) => {
const deps = {};
for (const [nome, checar] of Object.entries(DEPS)) {
const t0 = Date.now();
try {
await checar();
deps[nome] = { ok: true, ms: Date.now() - t0 };
} catch (e) {
deps[nome] = { ok: false, ms: Date.now() - t0, erro: String(e.message).slice(0, 120) };
}
}
const degradado = Object.values(deps).some((d) => !d.ok);
res.status(degradado ? 503 : 200).json({ deps });
});
Segundo, o alerta passou a olhar taxa de erro por rota, que é o número que o usuário sente:
- alert: TaxaErro5xxPorRota
expr: |
sum by (rota) (rate(http_requests_total{status=~"5.."}[5m]))
/ sum by (rota) (rate(http_requests_total[5m])) > 0.05
for: 2m
labels: { severity: pagina }
Terceiro, e foi o mais barato de todos: o tempo de detecção virou campo obrigatório no registro do incidente. Quem fecha o incidente escreve o horário do primeiro erro no log e o horário do primeiro sinal interno. Sem isso a conversa volta para impressão, e impressão sempre diz que a gente percebeu rápido.
Onde essa medição falha
Rota de volume baixo não entra. Com total >= 20 por bucket de 5 minutos, um endpoint que recebe 3 chamadas por minuto nunca dispara, e foi exatamente numa rota dessas que ficou o pior caso de 6h40. Não sei qual é o critério certo aí. Contagem absoluta de erro gera ruído, taxa não acusa nada.
O readiness profundo também tem contraindicação. Se o orquestrador bate nele a cada 5 segundos e o load balancer tira a réplica do ar quando volta 503, uma oscilação de 2 segundos no gateway de pagamento derruba a frota inteira de uma vez. Aqui o /health/ready ficou fora do probe do Kubernetes e serve só para alerta e inspeção manual. Liveness continua sendo o 200 bobo.
E tem o outro extremo. Em serviço com um time de duas pessoas, montar essa coleta custa uns dois dias, e dois dias podem ser 5% de um projeto de seis semanas.
A pergunta que eu trouxe
Quem aqui registra tempo de detecção como campo do incidente, e não como frase na retrospectiva? E para rota de volume baixo, o que vocês usam no lugar da taxa de erro por janela, já que ela não acusa surto nenhum?
Publicado originalmente no blog da Revin: https://revin.com.br/pt/blog/como-avaliar-entrega-de-desenvolvedor-sem-ler-codigo