Trilha de Kotlin · parte 57 de 59
Observabilidade com Micrometer e OpenTelemetry
Aula da fase 8 da trilha de Kotlin: Como expor métricas, propagar traces e manter logs estruturados em JSON no mesmo serviço?
avançadoPublicada em · 13 min de leitura
Um serviço em produção fala com o operador de três formas diferentes. Os logs contam o que aconteceu, em frases e registros. As métricas mostram o comportamento agregado ao longo do tempo: quantos pedidos entraram, quanto demoraram, quantos falharam. Os traces contam o caminho de uma única requisição por todos os componentes que ela tocou. Quando alguém pergunta “por que o pedido 4821 demorou nove segundos?”, a resposta depende das três fontes juntas. Esta aula mostra como o projeto de exemplo produz as três, com Micrometer para métricas, OpenTelemetry para traces e logs estruturados em JSON, e registra a saída real de cada uma.
As versões são as da fase 7: Kotlin 2.3.21, Spring Boot 4.1.1 com Spring Framework 7.0.9, Micrometer 1.17.1 e o starter spring-boot-starter-opentelemetry na versão 4.1.1. Os trechos de log e de métrica citados abaixo foram copiados de uma execução da aplicação nesta revisão, com um PostgreSQL 17 num contêiner.
Uma analogia de painel de carro ajuda. A velocidade e o nível do combustível são métricas, sempre visíveis e agregadas. A luz de injeção que acende é um evento, com um contexto. O diagnóstico completo da oficina depende do histórico de cada componente naquele trajeto, que é o trace. Nenhum desses sinais substitui os outros, e o valor está em conseguir ligá-los pela mesma identificação.
o mapa da aula
A aula começa pelos três sinais e pelo que cada um responde, sem ferramenta alguma. Depois apresenta o Micrometer com um timer no adaptador de pagamento, incluindo o cuidado com a cardinalidade das tags, que é o erro mais caro desse tipo de código. A seção sobre traces mostra o starter do OpenTelemetry no Spring Boot 4, a amostragem e o destino OTLP. A última seção trata dos logs em JSON com traceId e spanId, que é o que liga os três sinais. Cada parte traz o que foi observado na execução e o que ficou apenas na documentação.
1. três sinais, três perguntas
A métrica responde perguntas de tendência: a taxa de recusas subiu nesta hora? O percentil 95 da cobrança piorou depois do último deploy? Ela é barata de armazenar porque agrega. Um contador guarda um número que só cresce, e um timer guarda contagem e soma, com percentis calculados sobre uma janela de tempo.
O log responde perguntas sobre um evento específico: o que aconteceu com o pedido 4821? Ele é detalhado e caro, por isso precisa de nível e de contexto. O trace responde perguntas sobre o caminho: onde a requisição gastou o tempo? Ele liga uma requisição HTTP a uma consulta no banco e a uma chamada de pagamento, cada um com início e fim.
A figura abaixo mostra por onde sai cada sinal quando um único pedido é processado.
O olhar começa no cliente, que envia o POST /pedidos. O controller registra um log, que segue para a saída padrão em JSON, e a cobrança alimenta o timer pedidos.cobranca. O Micrometer Tracing cria um span com o traceId e o spanId, que são enviados ao collector OTLP. O Prometheus aparece com uma linha pontilhada, porque ele não recebe dados do serviço: ele faz a coleta por scrape do endpoint /actuator/prometheus. Essa diferença entre o serviço empurrar dados e o Prometheus puxá-los é a razão pela qual o endpoint precisa estar exposto, enquanto o OTLP precisa de um destino ativo.
2. Micrometer: métricas no adaptador
O Micrometer é a fachada de métricas do ecossistema Spring. A versão 1.17.1 é a publicada no Maven Central e a que o BOM do Spring Boot 4.1.1 resolve no projeto. O Spring Boot cria um MeterRegistry composto e adiciona um registro para cada implementação no classpath. O registro do Prometheus, micrometer-registry-prometheus, expõe as métricas em formato de texto no endpoint de atuador.
Onde colocar a métrica é uma decisão de arquitetura. No projeto, o MetricasGatewayPagamento é um adaptador que envolve o gateway real e mede a cobrança. O domínio e a aplicação não importam Micrometer, o que a Aula Arquitetura hexagonal com ArchUnit verifica com a regra do domínio. O código é este:
class MetricasGatewayPagamento(
private val destino: GatewayPagamento,
private val registro: MeterRegistry,
) : GatewayPagamento {
override fun cobrar(pedido: Pedido): ResultadoCobranca {
val amostra = Timer.start(registro)
val resultado = destino.cobrar(pedido)
val status = when (resultado) {
ResultadoCobranca.Aprovada -> "aprovada"
is ResultadoCobranca.Recusada -> "recusada"
}
amostra.stop(registro.timer("pedidos.cobranca", "resultado", status))
return resultado
}
}
O padrão é um decorador: a classe implementa a mesma porta e delega para outra, acrescentando a medição. O Timer.Sample, criado com Timer.start, permite escolher a tag só depois do resultado, porque a tag resultado depende do que a cobrança devolveu. O timer é registrado com o nome pedidos.cobranca e uma tag com dois valores possíveis.
A escolha das tags é a parte que mais dá problema. Uma tag com valores ilimitados, como o ID do pedido, cria uma série nova para cada valor, e o backend de métricas paga o custo de cada uma. O exemplo do projeto usa aprovada e recusada, dois valores fixos. Se a tag fosse o ID do pedido, cada pedido criaria uma série nova. Por isso o ID do pedido vai para o log e para o trace, e nunca para a métrica. Essa regra é de prática geral, e a documentação do Micrometer sobre tags de alta cardinalidade não foi reconferida nesta revisão.
Depois de uma aprovação e de outra requisição, o endpoint de Prometheus mostrou as séries abaixo. Os dois números vêm da mesma execução:
http_server_requests_seconds_count{error="none",exception="none",method="POST",outcome="SUCCESS",status="200",uri="/pedidos"} 2
pedidos_cobranca_seconds_count{resultado="aprovada"} 2
A linha do HTTP veio do próprio Spring Boot, sem nenhum código do projeto. Esse é o ganho de usar o atuador: o tempo de cada requisição já é medido. A métrica de negócio, como o pedidos.cobranca, precisa ser declarada pelo time. Nesta execução, todos os pedidos foram aprovados, porque o gateway de verificação é um stub que aprova sempre. A série resultado="recusada" não aparece porque o caminho da recusa não foi exercitado com o stub.
O endpoint só responde porque a configuração expõe o Prometheus. A configuração do projeto tem management.endpoints.web.exposure.include=health,prometheus, e a documentação do Spring Boot confirma que o endpoint precisa ser exposto explicitamente.
Pergunta de entrevistaQuais são os três sinais de observabilidade?
Métricas, logs e traces. Métricas mostram quanto e com que frequência algo acontece, como taxa de erro e latência. Logs descrevem o que aconteceu num evento específico. Traces mostram o caminho de uma requisição entre serviços, com o tempo de cada etapa. Os três se ligam por identificadores, como o trace id no log. Um alerta costuma começar numa métrica e terminar num trace.
3. OpenTelemetry: traces que atravessam serviços
O OpenTelemetry é o padrão aberto para telemetria, e o Spring Boot 4 o integra por um starter, spring-boot-starter-opentelemetry, na versão 4.1.1. O starter traz a ponte entre a Observation API do Micrometer e o OpenTelemetry, por meio do Micrometer Tracing. Assim, o código da aplicação continua usando as mesmas observações, e o starter decide para onde o trace vai.
Três propriedades controlam o comportamento do projeto. A primeira é a amostragem. A documentação do Spring Boot diz que, por padrão, apenas 10% das requisições são amostradas, “to prevent overwhelming the trace backend”. O projeto usa management.tracing.sampling.probability=1.0, que amostra tudo. Em produção, esse valor precisa ser escolhido com o custo de armazenamento em mente, e não copiado do exemplo.
A segunda é o destino dos spans. A chave management.opentelemetry.tracing.export.otlp.endpoint aparece nos metadados de configuração do jar spring-boot-micrometer-tracing-opentelemetry na versão 4.1.1, o que confirma o nome. O projeto a usa com o valor http://localhost:4318/v1/traces. Uma forma abreviada dessa chave, fácil de lembrar errado, não aparece nos metadados.
A terceira é o formato do collector. O OTLP por HTTP usa a porta 4318, e o caminho dos traces é /v1/traces. Os valores padrão de endpoint do OpenTelemetry não foram reconferidos nesta revisão, então a aula não afirma que o padrão é localhost:4318. O projeto define o endpoint explicitamente.
O projeto não tem um collector rodando, e nenhum backend recebeu spans nesta revisão. O que a execução mostrou é o comportamento do exportador de métricas OTLP, que vem junto com o starter. Ao subir a aplicação, o log registrou esta linha, copiada sem edição:
"message":"Publishing metrics for OtlpMeterRegistry every 1m to http://localhost:4318/v1/metrics with resource attributes {service.name=unknown_service}"
Ao encerrar a aplicação sem collector disponível, o exportador registrou um aviso, e não uma falha que derrubasse o processo:
"level":"WARN","logger":"io.micrometer.registry.otlp.OtlpMeterRegistry", "message":"Failed to publish metrics to OTLP receiver (context: url=http://localhost:4318/v1/metrics, resource-attributes={service.name=unknown_service})"
Esse ponto surpreende quem copia a configuração: o starter já deixa exportadores ativos, e um destino fora do ar aparece como aviso. O aviso apareceu no encerramento, e não durante a execução, porque nesta revisão a exportação ocorreu em intervalos de um minuto. A forma de desligar o exportador de métricas OTLP não foi testada, então a aula não a recomenda. A lição geral é que o exportador de métricas OTLP é independente do endpoint de Prometheus, e os dois podem estar ativos ao mesmo tempo.
Um trace serve para acompanhar a requisição através de componentes. Um span é uma etapa cronometrada, com nome, início, fim e atributos. Um trace agrupa os spans de uma mesma requisição pelo identificador traceId. Em uma chamada HTTP entre serviços, o identificador viaja no cabeçalho, e o serviço receptor cria spans filhos. No projeto de exemplo há um único serviço, então a propagação entre serviços não foi testada.
4. logs estruturados: a ponte entre os sinais
Um log em texto livre é difícil de consultar em escala. Um log em JSON permite filtrar por campo. O Spring Boot oferece formatos prontos por uma propriedade, logging.structured.format.console, que aceita os valores ecs, gelf e logstash, ou o nome de uma classe própria. A configuração do projeto usa ecs. A documentação diz que os formatos ECS, GELF e Logstash incluem cada par do MDC no objeto JSON.
Um registro gerado pelo controller, quando um pedido é processado, saiu assim na saída padrão, copiado de uma requisição real desta revisão:
{"@timestamp":"2026-10-20T16:49:53.354335Z","log":{"level":"INFO","logger":"com.exemplo.pedidos.adaptadores.web.PedidoController"},"process":{"pid":39974,"thread":{"name":"http-nio-8087-exec-4"}},"service":{"node":{}},"message":"pedido processado id=ped-3 estado=Aprovado","traceId":"3094b6ceb95487fbf6596b5d28d28b67","spanId":"5476bf6c8b16914b","ecs":{"version":"8.11"}}
Os campos traceId e spanId estão presentes porque o Micrometer Tracing coloca esses valores no MDC para a requisição em andamento. A documentação do Spring Boot diz que, com o Micrometer Tracing ativo, os IDs de correlação entram nos logs por padrão, a partir dos valores traceId e spanId do MDC. Esse é o elo que liga os três sinais. Quando um log mostra um traceId com erro, o operador pode abrir o trace correspondente, e quando um trace mostra uma etapa lenta, pode buscar os logs dessa requisição.
Um detalhe que custou um passo a mais: a primeira execução desta aula não mostrou traceId em nenhuma linha, porque o controller não escrevia log. O contexto de log só aparece em linhas emitidas dentro da requisição. A linha de log do controller é o que faz a correlação aparecer, e a lição vale para qualquer serviço: a correlação existe, mas só aparece onde há uma linha de log.
O log também pode carregar campos extras pelo MDC. No exemplo, o id do pedido aparece na mensagem, e não como campo separado. Em um serviço real, o ideal é colocar esses identificadores como campos estruturados e não dentro da frase, para permitir a busca por campo.
5. erros comuns e boas práticas
O primeiro erro é usar um identificador de negócio como tag de métrica. O resultado é uma explosão de séries que derruba o backend de métricas, e a boa prática é deixar o identificador no log e no trace.
O segundo erro é esquecer de expor o endpoint do Prometheus, o que faz o scrape falhar sem que o serviço reclame. A boa prática é verificar o endpoint com uma requisição direta após cada mudança de configuração.
O terceiro erro é achar que um exportador ativo sem collector é um erro fatal. Como visto, ele vira aviso, e um aviso que ninguém lê é um problema de operação, não de código. A boa prática é ter o collector disponível em todos os ambientes em que o starter está ativo.
O quarto erro é amostrar 100% em produção sem pensar no custo, ou amostrar 10% padrão sem saber que erros raros podem não aparecer. A amostragem é uma decisão de custo e de cobertura, e deve ser escolhida com os números do serviço.
O quinto erro é fazer log sem contexto e depois se perguntar por que não dá para correlacionar. A correlação exige que a linha exista dentro da requisição, e que o framework tenha o MDC preenchido.
6. exercícios
- Adicione uma tag com o
iddo pedido ao timerpedidos.cobranca, rode dez pedidos com ids diferentes e leia o endpoint do Prometheus. Conte as séries criadas e explique o que aconteceria com mil pedidos por minuto. - Mude
management.tracing.sampling.probabilitypara0.1, faça vinte requisições e compare o número de linhas comtraceIdcom o número de requisições. Explique a diferença. - Suba um collector OTLP local e confirme que os spans chegam. Registre o endpoint real que você usou e a resposta dele.
- Crie um segundo serviço que chame o primeiro com o cliente HTTP do Spring Boot e verifique se o
traceIdse mantém entre os dois. Esse exercício depende de uma configuração de propagação que não foi testada nesta aula.
7. glossário
Métrica: valor agregado ao longo do tempo, como contador, timer ou gauge.
Timer: medição de duração e de contagem de eventos.
Cardinalidade: quantidade de valores distintos que uma tag pode assumir. Cardinalidade alta gera muitas séries.
Span: etapa cronometrada de uma requisição, com nome, início, fim e atributos.
Trace: conjunto de spans que pertencem à mesma requisição, identificados pelo traceId.
Amostragem: escolha de quais requisições terão trace registrado.
OTLP: protocolo do OpenTelemetry para enviar telemetria a um collector.
Scrape: coleta periódica de métricas que o Prometheus faz num endpoint do serviço.
MDC: contexto de diagnóstico por thread, usado para incluir campos nos logs.
fontes
Consultadas em 20/10/2026.
- Spring Boot, tracing (starter OpenTelemetry, amostragem de 10% por padrão, endpoints de configuração OTLP,
traceIdespanIdnos logs): https://docs.spring.io/spring-boot/reference/actuator/tracing.html - Spring Boot, log estruturado (
logging.structured.format.console, valoresecs,gelfelogstash, MDC nos formatos): https://docs.spring.io/spring-boot/reference/features/logging.html - Metadados de configuração do jar
spring-boot-micrometer-tracing-opentelemetry-4.1.1.jar, conferidos com leitura do arquivo: a chavemanagement.opentelemetry.tracing.export.otlp.endpointaparece nele. - Versões no Maven Central: https://repo1.maven.org/maven2/io/micrometer/micrometer-core/maven-metadata.xml (1.17.1 presente) e https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-starter-opentelemetry/maven-metadata.xml (4.1.1 presente)
Não verificado nesta revisão: as páginas do Micrometer sobre timers e tags de alta cardinalidade, a página do OpenTelemetry sobre os endpoints padrão do exportador OTLP, a chave management.tracing.sampling.probability nos metadados do Spring Boot, o apêndice de propriedades e o comportamento de propagação entre serviços.
Executado em 20/10/2026, em aplicação de verificação com Spring Boot 4.1.1, Kotlin 2.3.21, JDK 21 e PostgreSQL 17 em contêiner. O endpoint /actuator/prometheus expôs pedidos_cobranca_seconds_count e http_server_requests_seconds_count. O log trouxe traceId e spanId. A linha de publicação OTLP e o aviso de falha no encerramento estão citados acima. Não há collector no ambiente, então a chegada de spans a um backend não foi verificada.