Introdução ao Distributed Tracing
A arquitetura de microsserviços revolucionou a forma como desenvolvemos aplicações, permitindo escalabilidade e independência entre serviços. No entanto, ela introduziu um desafio crítico: como rastrear uma requisição quando ela atravessa múltiplos serviços? Quando um usuário faz uma requisição que passa por 5, 10 ou 20 serviços diferentes, entender o que aconteceu em cada etapa e onde surgem gargalos se torna extremamente complexo.
O Distributed Tracing resolve este problema capturando toda a jornada de uma requisição através dos serviços. Diferentemente dos logs tradicionais, que registram eventos isolados, o tracing fornece uma visão holística de como a requisição flui pela arquitetura. O Jaeger e o Zipkin são os dois principais frameworks open-source para implementar distributed tracing em produração, cada um com suas características distintas e casos de uso específicos.
Por que Distributed Tracing é essencial em microsserviços
Em um monólito, você pode simplesmente analisar logs de uma única aplicação. Em microsserviços, a mesma operação de negócio envolve múltiplos serviços, cada um rodando em containers diferentes, possivelmente em máquinas diferentes. Sem tracing distribuído, investigar um problema de performance ou uma falha se torna uma caça ao tesouro. O distributed tracing permite que você visualize o tempo gasto em cada serviço, identifique gargalos, detecte falhas intermitentes e correlacione eventos em diferentes serviços automaticamente.
Conceitos Fundamentais de Distributed Tracing
Trace, Span e Baggage
Um trace é o registro completo de uma requisição passando por toda a arquitetura. Pense nele como uma unidade de trabalho lógica que pode envolver múltiplos serviços. Cada trace é identificado por um trace_id único e imutável.
Um span é uma operação individual dentro de um trace. Se um trace é a jornada completa, um span é cada parada nessa jornada. Cada span possui: um span_id único, um trace_id (herdado do trace pai), um parent_span_id (se aplicável), timestamps de início e fim, e tags/logs adicionais. Os spans formam uma relação hierárquica em forma de árvore, permitindo visualizar a estrutura exata da execução.
Baggage é metadados que viajam junto com o trace através de todos os serviços. Exemplos: user_id, request_id customizado, tenant_id. O baggage permite correlacionar logs e métricas de diferentes serviços ao mesmo contexto de negócio.
Trace (user_id: 123, request_id: abc-xyz)
├── Span: HTTP GET /api/usuarios/123 (serviço A)
│ ├── Span: Query database (serviço A)
│ └── Span: HTTP GET /api/detalhes/123 (serviço B)
│ ├── Span: Cache lookup (serviço B)
│ └── Span: Query database (serviço B)
└── Span: Response assembly (serviço A)
Context Propagation
Para que diferentes serviços "saibam" que fazem parte do mesmo trace, o contexto (trace_id, span_id, baggage) deve ser propagado entre eles. Isso geralmente é feito através de headers HTTP. Os padrões mais comuns são:
- Jaeger Headers:
uber-trace-id,jaeger-baggage - W3C Trace Context (padrão moderno):
traceparent,tracestate,baggage - Zipkin Headers:
X-B3-TraceId,X-B3-SpanId,X-B3-ParentSpanId
Quando o serviço A faz uma chamada HTTP para o serviço B, inclui esses headers. O serviço B extrai o contexto, cria seus próprios spans como filhos, e continua propagando para o serviço C. Assim, toda a cadeia permanece conectada.
Jaeger: Arquitetura, Instalação e Uso Prático
Arquitetura do Jaeger
O Jaeger é mantido pela Cloud Native Computing Foundation e oferece uma arquitetura modular com três componentes principais:
- Jaeger Client: biblioteca que instrumento seu código para gerar spans
- Jaeger Agent: daemon que coleta spans dos clientes (porta UDP 6831 por padrão)
- Jaeger Backend: processa, armazena e disponibiliza a UI para consulta
A comunicação flui assim: sua aplicação → Jaeger Client → Jaeger Agent → Jaeger Collector → storage (Elasticsearch, Badger, etc.) → Jaeger Query UI.
Jaeger suporta múltiplas linguagens (Go, Java, Python, Node.js, C++, etc.) através de bibliotecas cliente. Para Java, usamos o jaeger-client, para Python jaeger-client, e assim por diante.
Instalação e Configuração
A forma mais prática de começar é com Docker Compose. Crie um arquivo docker-compose.yml:
version: '3'
services:
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "6831:6831/udp" # Jaeger agent (Thrift compact)
- "16686:16686" # Jaeger UI
- "14268:14268" # Jaeger collector HTTP
environment:
- COLLECTOR_ZIPKIN_HOST_PORT=:9411
Suba com docker-compose up -d. A UI estará em http://localhost:16686.
Instrumentação em Python
Vamos criar dois microsserviços simples em Flask com tracing do Jaeger. Primeiro, instale as dependências:
pip install flask jaeger-client opentelemetry-api opentelemetry-sdk
Serviço A (porta 5000):
from flask import Flask, request
import requests
from jaeger_client import Config
from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from opentelemetry import trace
app = Flask(__name__)
# Configurar Jaeger
def init_jaeger_tracer(service_name):
config = Config(
config={
'sampler': {'type': 'const', 'param': 1},
'local_agent': {'reporting_host': 'localhost', 'reporting_port': 6831},
'logging': True,
},
service_name=service_name,
)
return config.initialize_tracer()
tracer = init_jaeger_tracer('servico-a')
# Instrumentar Flask e requests automaticamente
FlaskInstrumentor().instrument_app(app)
RequestsInstrumentor().instrument()
@app.route('/processar/<usuario_id>')
def processar(usuario_id):
# Criar span manual se necessário
with tracer.start_active_span('buscar-dados-usuario') as scope:
scope.span.set_tag('usuario_id', usuario_id)
# Chamar serviço B
response = requests.get(f'http://localhost:5001/dados/{usuario_id}')
dados = response.json()
return {'resultado': 'sucesso', 'dados': dados}
if __name__ == '__main__':
app.run(port=5000, debug=False)
Serviço B (porta 5001):
from flask import Flask
from jaeger_client import Config
from opentelemetry.instrumentation.flask import FlaskInstrumentor
app = Flask(__name__)
def init_jaeger_tracer(service_name):
config = Config(
config={
'sampler': {'type': 'const', 'param': 1},
'local_agent': {'reporting_host': 'localhost', 'reporting_port': 6831},
'logging': True,
},
service_name=service_name,
)
return config.initialize_tracer()
tracer = init_jaeger_tracer('servico-b')
FlaskInstrumentor().instrument_app(app)
@app.route('/dados/<usuario_id>')
def obter_dados(usuario_id):
with tracer.start_active_span('query-database') as scope:
scope.span.set_tag('usuario_id', usuario_id)
# Simular operação de banco
dados = {'id': usuario_id, 'nome': 'João Silva', 'email': 'joao@example.com'}
return dados
if __name__ == '__main__':
app.run(port=5001, debug=False)
Agora, inicie ambos os serviços em terminais diferentes:
python servico_a.py
python servico_b.py
Faça uma requisição:
curl http://localhost:5000/processar/123
Acesse http://localhost:16686 e procure pelo trace. Você verá toda a cadeia: Serviço A → Serviço B, com tempos de execução de cada span.
Zipkin: Diferenças, Instalação e Uso Prático
Características do Zipkin
Zipkin é um projeto mais maduro (origem no Twitter) focado em simplicidade e performance. Enquanto Jaeger oferece maior flexibilidade e features avançadas, Zipkin destaca-se por ser leve e direto. Zipkin também suporta múltiplas linguagens e usa o padrão B3 de headers de propagação de contexto.
A arquitetura é similar: clientes geram spans, enviam para o Zipkin (via HTTP ou Kafka), dados são armazenados e consultados na UI. Zipkin oferece suporte nativo a Elasticsearch, MySQL e Cassandra como backends.
Instalação com Docker Compose
Crie um docker-compose.yml para Zipkin:
version: '3'
services:
zipkin:
image: openzipkin/zipkin:latest
ports:
- "9410:9410" # Zipkin UI (diferente de Jaeger!)
- "9411:9411" # Zipkin collector HTTP
Levante com docker-compose up -d. A UI estará em http://localhost:9410.
Instrumentação em Java (Spring Boot)
Para Java com Spring Boot, o processo é ainda mais simples. Adicione ao pom.xml:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-sleuth</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-sleuth-zipkin</artifactId>
</dependency>
E configure no application.yml:
spring:
zipkin:
base-url: http://localhost:9411/
sender:
type: web
sleuth:
sampler:
probability: 1.0 # Coletar 100% dos traces (ajuste em produção)
Serviço A (porta 8080):
package com.exemplo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class ServicoA {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
public static void main(String[] args) {
SpringApplication.run(ServicoA.class, args);
}
}
@RestController
class ControladorA {
private final RestTemplate restTemplate;
public ControladorA(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
@GetMapping("/processar/{usuarioId}")
public Map<String, Object> processar(@PathVariable String usuarioId) {
// O Spring Sleuth automaticamente adiciona headers B3 a cada requisição
String dados = restTemplate.getForObject(
"http://localhost:8081/dados/" + usuarioId,
String.class
);
return Map.of("resultado", "sucesso", "dados", dados);
}
}
Serviço B (porta 8081):
package com.exemplo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
public class ServicoB {
public static void main(String[] args) {
SpringApplication.run(ServicoB.class, args);
}
}
@RestController
class ControladorB {
@GetMapping("/dados/{usuarioId}")
public Map<String, Object> obterDados(@PathVariable String usuarioId) {
// Simular consulta ao banco
return Map.of(
"id", usuarioId,
"nome", "Maria Santos",
"email", "maria@example.com"
);
}
}
Configure as portas em application.yml de cada serviço:
server:
port: 8080 # ou 8081 para Serviço B
Após iniciar ambos, faça:
curl http://localhost:8080/processar/123
Acesse http://localhost:9410 e procure pelos traces. Você verá a chamada de A para B rastreada automaticamente, pois o Spring Sleuth injeta os headers B3 transparentemente.
Comparação Prática: Jaeger vs Zipkin
Características-chave
| Aspecto | Jaeger | Zipkin |
|---|---|---|
| Origem | Uber (CNCF) | |
| Maturidade | Mais recente, inovador | Mais estabelecido |
| Sampling | Mais flexível (adaptive sampling) | Básico (fixed rate) |
| Storage | Elasticsearch, Badger, Cassandra | Elasticsearch, MySQL, Cassandra |
| Headers | Jaeger, W3C Trace Context | B3 (padrão) |
| Curva de aprendizado | Moderada | Suave |
| Performance | Excelente com Badger | Excelente em geral |
| UI | Avançada, com gráficos complexos | Simples e intuitiva |
| Deploy | Mais componentizado | Mais monolítico |
Quando usar cada um
Use Jaeger se: você precisa de sampling adaptativo baseado em taxa de erro, quer máxima flexibilidade na propagação de contexto (W3C), trabalha em ambiente Kubernetes/CNCF ou precisa de features avançadas como trace comparisons e node graphs.
Use Zipkin se: você quer simplicidade, já trabalha com Spring Boot/Spring Cloud, precisa de uma solução leve, ou prefere uma UI mais minimalista que focam em essencial.
Na prática, ambos resolvem o problema de distributed tracing de forma excelente. A escolha muitas vezes depende do seu ecossistema existente e preferências de arquitetura.
Padrões Avançados e Boas Práticas
Sampling em Produção
Coletar 100% dos traces em produção é caro e desnecessário. Implementar sampling adequado é crítico. Jaeger oferece várias estratégias:
Constant Sampling: coleta uma fração fixa (ex: 10% de todos os traces)
Probabilistic Sampling: coleta aleatoriamente com uma probabilidade
Rate Limiting Sampling: coleta até um máximo de spans por segundo
Remote Sampling: o servidor Jaeger decide qual percentage usar dinamicamente
Para Zipkin com Spring Boot:
spring:
sleuth:
sampler:
probability: 0.1 # 10% dos traces
Para maior controle com Jaeger e Python:
config = Config(
config={
'sampler': {
'type': 'probabilistic',
'param': 0.1, # 10%
},
},
service_name='meu-servico',
)
Baggage para Contexto de Negócio
Propagar contexto de negócio como user_id ou tenant_id é essencial. Em Java com Sleuth:
import org.springframework.cloud.sleuth.Tracer;
@RestController
public class MeuController {
private final Tracer tracer;
public MeuController(Tracer tracer) {
this.tracer = tracer;
}
@GetMapping("/comprar")
public void comprar(@RequestParam String usuarioId) {
// Adicionar ao baggage para propagar em todas as chamadas subsequentes
tracer.getBaggage("usuario_id").set(usuarioId);
// Chamadas subsequentes incluirão usuario_id automaticamente
servicoB.processar();
}
}
Em Python com Jaeger:
with tracer.start_active_span('minha-operacao') as scope:
scope.span.set_tag('usuario_id', usuario_id)
scope.span.set_tag('tenant_id', tenant_id)
# Estes tags ficarão visíveis na UI e nos logs
Logs estruturados integrados com tracing
A verdadeira potência emerge quando logs estruturados incluem trace_id. Assim, você correlaciona logs com traces:
Python com Python Logging:
import logging
from pythonjsonlogger import jsonlogger
from jaeger_client import Config
# Configurar JSON logging
logHandler = logging.StreamHandler()
formatter = jsonlogger.JsonFormatter()
logHandler.setFormatter(formatter)
logger = logging.getLogger()
logger.addHandler(logHandler)
logger.setLevel(logging.INFO)
config = Config(
config={
'sampler': {'type': 'const', 'param': 1},
'local_agent': {'reporting_host': 'localhost', 'reporting_port': 6831},
},
service_name='meu-servico',
)
tracer = config.initialize_tracer()
@app.route('/operacao')
def operacao():
span = tracer.start_span('operacao-principal')
# Adicionar trace_id ao contexto de logging
logger.info('Iniciando operação', extra={
'trace_id': span.context.trace_id,
'span_id': span.context.span_id,
})
# ... seu código ...
span.finish()
Resultado nos logs: {"trace_id": 123456, "span_id": 789, "message": "Iniciando operação"}. Agora você pode filtrar logs por trace_id na mesma ferramenta que filtra traces.
Conclusão
Três aprendizados principais que você deve levar consigo:
-
Distributed Tracing é não-negociável em microsserviços: sem ele, você está navegando às cegas. Traces conectam eventos isolados em um contexto único, permitindo investigação rápida de problemas e identificação de gargalos reais.
-
Jaeger e Zipkin diferem em filosofia, não em core function: Jaeger é mais flexível e componentizado (melhor para ambientes complexos), Zipkin é mais leve e direto (melhor para começar simples). Ambos resolvem o problema. Escolha com base no seu ecossistema, não por modismo.
-
Sampling e baggage são a base de um tracing efetivo em produção: coletar tudo é inviável e caro. Implementar sampling correto (10-25% em produção) reduz custos drasticamente. Propagar contexto de negócio (user_id, tenant_id) através de baggage permite correlacionar eventos entre serviços em nível de lógica de negócio, não apenas técnico.