Pular para conteúdo

0028 — Heartbeat do integrador-client, alerta de silêncio e CENTRAL_API_TOKEN

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10 · Decidido em: 2026-09-10

Decisão local do central-backend. Não confundir com o ADR central 0028 da casa (sync event-driven das docs), que o application.yml também cita.

Contexto

O integrador-client é um jar Java 8 que cada cliente roda on-premise (Windows, Agendador de Tarefas) — um app com N instalações, uma por cliente. Uma instalação que para (máquina desligada, jar travado, .properties quebrado) ninguém vê: o write-back para de chegar e o sintoma aparece horas ou dias depois, relatado pelo cliente. A casa também não sabe, sem acesso remoto, qual versão roda em cada cliente nem quais fluxos estão ligados. E não há cadastro de instalação: o cliente recebe o jar e começa a rodar.

Decisão

  1. Heartbeat em dois saltos, pelo integrador-server do cliente. O client manda o heartbeat (versão, fluxos, hostname) no startup e a cada 60 min ao integrador-server do próprio cliente (int.<cliente>.xadm.biz, destino que o firewall já libera, com o Bearer que o write-back já usa). O integrador-server carimba o cliente_id (env CLIENTE_ID) e repassa ao central em POST /api/integrador/heartbeat. Carimbar no integrador-server cumpre a norma de tenant (identidade do contexto autenticado, nunca do corpo): cada instância é de um cliente só.
  2. Contrato congelado entre os 4 repos, com amostras canônicas copiadas como fixture de teste em quem produz e em quem consome. A casa durável de K2–K4 (o que o central expõe) é o OpenAPI publicado e o contrato da API REST; a de K1 (client → integrador-server) é do integrador-server.
  3. Token M2M próprio, CENTRAL_API_TOKEN, com filtro próprio (IntegradorAuthFilter) que só abre /api/integrador/**. Nome pelo destino (<DESTINO>_API_TOKEN), um valor compartilhado por todos os integrador-servers. Não reusa o AUTH_INTEGRATOR_TOKEN (sync de senhas pAbast), que abre todo o /api/admin/**. Em prod, token vazio recusa o boot (IntegradorTokenGuard).
  4. Registro automático por upsert: uma linha por cliente em integrador_instancias, criada no 1º heartbeat. Sem @Transactional e sem lock — a leitura prévia só escolhe o log ("registrado" ou "voltou"). cliente_id fora de clientes → 422 cliente_desconhecido + WARN (misconfiguração do integrador-server), sem linha.
  5. Silêncio medido em horas úteis: 07:00–22:00, seg–sex, sem feriado nacional, em America/Sao_Paulo. Passou de 6h úteis, o SilencioJob (a cada 15 min) alerta uma vez por episódio. Carnaval e Corpus Christi (ponto facultativo) contam como feriado — menos alerta falso. Régua no código, sem property.
  6. Eleição por claim condicional, não por lock: UPDATE … SET alertado_em = :agora WHERE alertado_em IS NULL AND ultimo_heartbeat_em = :visto. Só a réplica que vira a linha alerta; um heartbeat que chegou entre a leitura e o claim faz o claim falhar (sem alerta falso).
  7. Alerta = evento do GlitchTip do central com fingerprint por cliente (["integrador-client-silencioso", <cliente_id>], tag cliente), via Sentry.captureMessage, mais WARN no log — nunca LOG.error, que o SentryAppender duplicaria. Uma issue por cliente: o alerta padrão do GlitchTip notifica a cada evento, então reincidências notificam do mesmo jeito.
  8. Um relógio só (Clock injetado, ClockFactory) carimba, reivindica e mede. O instante gravado é o do servidor, nunca o do client.
  9. API admin sem paginação e com DELETE idempotente. GET /api/admin/integradores é lookup fixo pequeno (no máximo uma linha por cliente), a isenção de paginação da norma — mesmo precedente do GET /api/admin/clients. O DELETE responde 204 com ou sem a linha: um 404 numa requisição com credencial vira ERROR no GlitchTip (regra da xadm-comum-web 0.7.1+), e cada corrida benigna abriria issue. É o botão "Parar de acompanhar" da aba Deploy do central-ui.

Alternativas descartadas

  • Client direto ao central. Exigiria liberar um destino novo no firewall de cada cliente e distribuir um token novo em cada instalação.
  • Heartbeat pela fila do PowerSync. É canal de dado de negócio; misturar sinal de vida nele acopla a observabilidade à saúde do próprio canal que ela observa.
  • Fallback do cliente_id para o CLIENTE do integrador-server. O CLIENTE é nome de exibição (Maxsul, On Petro Trading…) e nunca passaria na regex; o fallback esconderia a misconfiguração.
  • Reusar o AUTH_INTEGRATOR_TOKEN. Abriria todo o /api/admin/** a cada integrador-server.
  • Fingerprint por episódio (issue nova a cada silêncio). Não notifica mais do que por cliente e aumenta a exposição ao corte do smoke pós-deploy (issue nova reprova; reincidência só avisa).
  • pg_try_advisory_lock para eleger a réplica. Mesmo efeito com lock de sessão e conexão presa; o claim condicional é idempotente por construção.
  • Outbox, retry ou histórico de heartbeats. O heartbeat é periódico — perder um é irrelevante, o próximo cobre. O estado atual basta para "está vivo?".

Consequências

  • CENTRAL_API_TOKEN é obrigatório nos dois recursos do central (jar e native), como o CENTRAL_DEPLOY_TOKEN — sem ele o container não sobe. Ver o runbook de deploy e o runbook do heartbeat.
  • O bind do String[] na @Query nativa (com @TypeDef(STRING_ARRAY) no parâmetro) é provado na JVM pelo teste; no binário native, só pelo passo 1 do smoke real depois da release.
  • O primeiro alerta da vida de um cliente dentro da janela do smoke pós-deploy pode reprovar um deploy do central (o central não carimba release no Sentry, então a camada 3 corta por tempo). O initialDelay = 20m do job tira a réplica nova dessa janela; o risco residual está aceito.
  • Regra de notificação do GlitchTip do projeto do central tem de ser por evento, não só "issue nova" — senão as reincidências não notificam. Ver provedor GlitchTip.
  • Apagar não desliga: parar de acompanhar uma instalação cujo jar ainda roda só dura até o próximo heartbeat. O desligamento de verdade é no client (heartbeat.intervaloMinutos=0).