Ir para o conteúdo
Trilha
Capítulos

Aprender

Saúde e observabilidade

Sondas de vida e prontidão, métricas no formato Prometheus e correlação de log, com o cuidado de não transformar monitoração em vazamento.

Um app em produção precisa responder três perguntas para quem o opera: está de pé?, pode receber tráfego? e o que está acontecendo?. O Trilha responde às três sem dependência nenhuma, e responde de um jeito que não entrega o mapa da sua infraestrutura para quem passar na rua.

A referência aqui é dupla, como no capítulo de segurança: o NIST SP 800-53r5 (AU-2 e AU-3 para o conteúdo do registro, AU-9 para proteger essa informação, SI-4 para monitoração e SC-5 contra negação de serviço) e o OWASP (Top 10 2021 A09, API Security 2023 API8 e o capítulo V7 do ASVS).

As duas sondas#

Sem configurar nada, todo app Trilha já responde:

EndereçoPerguntaExecuta verificações?
/_trilha/health/liveo processo consegue atender?não
/_trilha/health/readypode receber tráfego?sim
/_trilha/healthigual a readysim

A separação não é burocracia. No Kubernetes, uma readiness que falha tira o pod do balanceador; uma liveness que falha mata o processo. Se as duas rodassem a mesma verificação de banco, uma oscilação de rede reiniciaria a frota inteira em vez de esperar o banco voltar. Por isso live nunca toca em dependência alguma.

// app/setup.go
func Setup(a *trilha.App) error {
	a.Check("banco", func(ctx context.Context) error {
		return db.PingContext(ctx)
	})
	a.Check("fila", func(ctx context.Context) error {
		return fila.Ping(ctx)
	})
	return nil
}

Cada verificação roda com prazo (2 s por padrão) e em paralelo; um panic dentro dela vira falha, não derruba o processo. O resultado fica em cache por 1 s: uma sonda por segundo — ou dez mil por segundo, vindas de alguém mal-intencionado — não viram dez mil SELECT 1 no seu banco.

O que o anônimo vê#

Em produção, sem autorização, a resposta é exatamente esta:

{"status":"fail"}

Nome da verificação, mensagem de erro, hostname e versão ficam de fora de propósito (ASVS V7.4.1). Saber que existe um Postgres chamado financeiro e que ele está fora do ar é meio caminho para quem está sondando o alvo. A causa vai para o log, onde já existe controle de acesso, e para quem se autentica:

curl -H "Authorization: Bearer $TRILHA_OBS_TOKEN" https://app/_trilha/health
{"status":"fail","checks":[{"name":"banco","status":"fail","duration_ms":2001.4,
 "error":"prazo esgotado: context deadline exceeded"}],"uptime_seconds":8134.2}

Em dev o detalhe é aberto — lá o alvo é você mesmo.

Métricas#

O endereço de métricas não existe até você pedir. Isso é deliberado: um /metrics público é a má configuração descrita no API8 do OWASP, e ele conta ao visitante quantas rotas você tem, quais têm erro e a que horas o tráfego cai.

func Config(cfg *trilha.Config) {
	cfg.Observability.Metrics = "/_trilha/metrics"   // ou TRILHA_METRICS
	// TRILHA_OBS_TOKEN (32+ bytes) autoriza a raspagem;
	// alternativa: Trusted com o CIDR do coletor.
	cfg.Observability.Trusted = []string{"10.42.0.0/16"}
}

A saída é o formato de texto do Prometheus, então Prometheus, VictoriaMetrics, Grafana Alloy e OpenTelemetry Collector leem sem tradutor:

trilha_requests_total{method="GET",route="/blog/{slug}",status="200"} 1841
trilha_request_duration_seconds_bucket{method="GET",route="/blog/{slug}",le="0.05"} 1802
trilha_requests_in_flight 3
trilha_security_events_total{kind="csrf"} 2
trilha_panics_total 0
go_goroutines 14

Repare no rótulo route: é o padrão registrado, /blog/{slug}, nunca o caminho concreto /blog/como-fiz-x. Caminho concreto é entrada do usuário — traz identificador, às vezes traz token na query string, e faz o número de séries crescer sem limite até a memória acabar. O que não casa com rota registrada (estático, 404) cai num único rótulo other, e cada métrica tem teto de séries (mil por padrão).

Métrica sua entra no mesmo lugar:

posts.Publicados = a.Metrics().Counter("blog_posts_total", "Posts publicados.")
lentidao := a.Metrics().Histogram("blog_render_seconds", "Tempo de render.", nil, "template")
lentidao.With("post").Observe(dur.Seconds())

Achar uma requisição no log#

Todo log de requisição já sai com request_id, e o mesmo valor volta no cabeçalho X-Request-ID. Quando o cliente manda traceparent (W3C Trace Context — é o que um gateway, um Istio ou um SDK de OpenTelemetry mandam), o trace_id entra junto:

func GET(c *trilha.Ctx) error {
	c.Log().Info("consultando fornecedor", "cnpj", cnpj)  // request_id + trace_id
	return c.JSON(200, resp)
}

O Trilha propaga o contexto e o coloca no log; ele não exporta spans nem amostra traços. Rastreamento distribuído completo é trabalho de um coletor, e prendê-lo ao core custaria dezenas de dependências.

O que nunca entra no log, por decisão de projeto: corpo da requisição, cookies, cabeçalho Authorization e query string (ASVS V7.1.1). É lá que segredo viaja.

Custo#

Com o endereço de métricas desligado, a instrumentação não roda: é uma comparação de ponteiro. Ligada, ela custa zero alocações por requisição (duas buscas em mapa com chave montada na pilha e alguns incrementos atômicos); a diferença de tempo fica dentro do ruído da máquina de referência. Os números estão em Desempenho.

Desafio#

Faça o /_trilha/health/ready do seu app verificar o banco e um serviço externo, com prazo de 500 ms para o externo; exponha as métricas só para a rede 10.0.0.0/8; e conte, numa métrica sua, quantas vezes o serviço externo falhou.

Mostrar solução
// app/setup.go
func Config(cfg *trilha.Config) {
	cfg.Observability.Metrics = "/_trilha/metrics"
	cfg.Observability.Trusted = []string{"10.0.0.0/8"}
}

func Setup(a *trilha.App) error {
	falhas := a.Metrics().Counter("integracao_falhas_total", "Falhas na consulta ao parceiro.", "servico")

	a.Check("banco", func(ctx context.Context) error { return db.PingContext(ctx) })

	a.Check("parceiro", func(ctx context.Context) error {
		ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
		defer cancel()
		if err := parceiro.Ping(ctx); err != nil {
			falhas.With("parceiro").Inc()
			return err
		}
		return nil
	})
	return nil
}

O prazo curto do parceiro convive com o prazo geral (Observability.Timeout): vale o que vencer primeiro. E como o contador é criado no Setup, ele aparece na raspagem desde a primeira requisição, com valor zero — o que é melhor do que sumir do painel até a primeira falha.