1

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

Carregando publicação patrocinada...