Vamos discutir uma questão importante: como monitoramos nossos serviços se algo der errado?
Por um lado, temos Prometheus com alertas e Kibana para dashboards e outras funcionalidades úteis. Também sabemos como coletar logs — a pilha ELK é nossa solução preferida. No entanto, o registro simples nem sempre é suficiente: ele não fornece uma visão holística da jornada de uma requisição através de todo o ecossistema de componentes.
Você pode encontrar mais informações sobre ELK aqui.
Mas e se quisermos visualizar as requisições? E se precisarmos correlacionar requisições que transitam entre sistemas? Isso se aplica tanto a microsserviços quanto a monólitos — não importa quantos serviços temos; o que importa é como gerenciamos sua latência.
De fato, cada requisição de usuário pode passar por toda uma cadeia de serviços independentes, bancos de dados, filas de mensagens e APIs externas.
Em um ambiente tão complexo, torna-se extremamente difícil identificar exatamente onde ocorrem os atrasos, descobrir qual parte da cadeia atua como um gargalo de desempenho e encontrar rapidamente a causa raiz das falhas quando elas acontecem.
Para enfrentar esses desafios de forma eficaz, precisamos de um sistema centralizado e consistente para coletar dados de telemetria — rastros, métricas e logs. É aqui que o OpenTelemetry e o Jaeger entram em cena.
Vamos Olhar para os Fundamentos
Existem dois termos principais que precisamos entender:
ID de Rastreio
Um ID de Rastreio é um identificador de 16 bytes, frequentemente representado como uma string hexadecimal de 32 caracteres. Ele é gerado automaticamente no início de um rastreamento e permanece o mesmo em todas as spans criadas por uma solicitação específica. Isso facilita a visualização de como uma solicitação percorre diferentes serviços ou componentes em um sistema.
ID de Span
Cada operação individual dentro de um rastreamento recebe seu próprio ID de Span, que é tipicamente um valor de 64 bits gerado aleatoriamente. As spans compartilham o mesmo ID de Rastreio, mas cada uma tem um ID de Span exclusivo, permitindo que você identifique exatamente qual parte do fluxo de trabalho cada span representa (como uma consulta a um banco de dados ou uma chamada para outro microsserviço).
Como Eles Estão Relacionados?
ID de Rastreio e ID de Span se complementam.
Quando uma solicitação é iniciada, um ID de Rastreio é gerado e passado para todos os serviços envolvidos. Cada serviço, por sua vez, cria uma span com um ID de Span único vinculado ao ID de Rastreio, permitindo que você visualize todo o ciclo de vida da solicitação do início ao fim.
Certo, então por que não usar apenas o Jaeger? Por que precisamos do OpenTelemetry (OTEL) e todas as suas especificações? Essa é uma ótima pergunta! Vamos desmembrá-la passo a passo.
Saiba mais sobre o Jaeger aqui.
Resumindo
- Jaeger é um sistema para armazenar e visualizar rastros distribuídos. Ele coleta, armazena, pesquisa e exibe dados mostrando como as requisições “viajam” pelos seus serviços.
- OpenTelemetry (OTEL) é um padrão (e um conjunto de bibliotecas) para coletar dados de telemetria (rastros, métricas, logs) de suas aplicações e infraestrutura. Não está vinculado a nenhuma ferramenta de visualização ou backend específico.
Resumindo:
- OTEL é como uma “língua universal” e um conjunto de bibliotecas para coleta de telemetria.
- Jaeger é um backend e UI para visualizar e analisar rastros distribuídos.
Por que precisamos do OTEL se já temos o Jaeger?
1. Um Padrão Único para Coleta
No passado, havia projetos como OpenTracing e OpenCensus. O OpenTelemetry unifica essas abordagens para coletar métricas e rastros em um único padrão universal.
2. Integração Fácil
Você escreve seu código em Go (ou outra linguagem), adiciona bibliotecas do OTEL para injetar automaticamente interceptores e spans, e é isso. Depois, não importa para onde você quer enviar esses dados—Jaeger, Tempo, Zipkin, Datadog, um backend personalizado—o OpenTelemetry cuida da parte técnica. Você apenas troca o exportador.
3. Não Apenas Rastros
O OpenTelemetry abrange rastros, mas também lida com métricas e logs. Você acaba com um único conjunto de ferramentas para todas as suas necessidades de telemetria, não apenas para rastreamento.
4. Jaeger como um Backend
Jaeger é uma excelente escolha se você está principalmente interessado na visualização de rastreamento distribuído. Mas ele não fornece a instrumentação entre linguagens por padrão. OpenTelemetry, por outro lado, oferece uma maneira padronizada de coletar dados, e então você decide para onde enviá-los (incluindo Jaeger).
Na prática, eles frequentemente trabalham juntos:
Seu aplicativo usa OpenTelemetry → se comunica via protocolo OTLP → vai para o Coletor OpenTelemetry (HTTP ou grpc) → exporta para Jaeger para visualização.
Parte Técnica
Design do Sistema (Um Pouco)
Vamos rapidamente esboçar alguns serviços que farão o seguinte:
- Serviço de Compra – processa um pagamento e o registra no MongoDB
- CDC com Debezium – escuta por alterações na tabela MongoDB e as envia para o Kafka
- Processador de Compra – consome a mensagem do Kafka e chama o Serviço de Autenticação para procurar o
user_id
para validação - Serviço de Autenticação – um serviço simples de usuários
No resumo:
- 3 serviços Go
- Kafka
- CDC (Debezium)
- MongoDB
Parte do Código
Vamos começar com a infraestrutura. Para unir tudo em um sistema, vamos criar um grande arquivo Docker Compose. Começaremos configurando a telemetria.
Nota: Todo o código está disponível através de um link no final do artigo, incluindo a infraestrutura.
services
jaeger
image jaegertracing/all-in-one1.52
ports
"6831:6831/udp" # UDP port for the Jaeger agent
"16686:16686" # Web UI
"14268:14268" # HTTP port for spans
networks
internal
prometheus
image prom/prometheus latest
volumes
./prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports
"9090:9090"
depends_on
kafka
jaeger
otel-collector
command
--config.file=/etc/prometheus/prometheus.yml
networks
internal
otel-collector
image otel/opentelemetry-collector-contrib0.91.0
command'--config=/etc/otel-collector.yaml'
ports
"4317:4317" # OTLP gRPC receiver
volumes
./otel-collector.yaml:/etc/otel-collector.yaml
depends_on
jaeger
networks
internal
Também vamos configurar o coletor — o componente que reúne a telemetria.
Aqui, escolhemos gRPC para transferência de dados, o que significa que a comunicação ocorrerá via HTTP/2:
receivers
# Add the OTLP receiver listening on port 4317.
otlp
protocols
grpc
endpoint"0.0.0.0:4317"
processors
batch
# https://github.com/open-telemetry/opentelemetry-collector/tree/main/processor/memorylimiterprocessor
memory_limiter
check_interval 1s
limit_percentage80
spike_limit_percentage15
extensions
health_check
exporters
otlp
endpoint"jaeger:4317"
tls
insecuretrue
prometheus
endpoint 0.0.0.09090
debug
verbosity detailed
service
extensions health_check
pipelines
traces
receivers otlp
processors memory_limiter batch
exporters otlp
metrics
receivers otlp
processors memory_limiter batch
exporters prometheus
Certifique-se de ajustar quaisquer endereços conforme necessário, e você terminou a configuração básica.
Já sabemos que o OpenTelemetry (OTEL) utiliza dois conceitos chave — ID de Rastreio e ID de Span — que ajudam a rastrear e monitorar solicitações em sistemas distribuídos.
Implementando o Código
Agora, vamos ver como fazer isso funcionar no seu código Go. Precisamos das seguintes importações:
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.17.0"
Então, adicionamos uma função para inicializar nosso rastreador em main()
quando a aplicação inicia:
func InitTracer(ctx context.Context) func() {
exp, err := otlptrace.New(
ctx,
otlptracegrpc.NewClient(
otlptracegrpc.WithEndpoint(endpoint),
otlptracegrpc.WithInsecure(),
),
)
if err != nil {
log.Fatalf("failed to create OTLP trace exporter: %v", err)
}
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceNameKey.String("auth-service"),
semconv.ServiceVersionKey.String("1.0.0"),
semconv.DeploymentEnvironmentKey.String("stg"),
),
)
if err != nil {
log.Fatalf("failed to create resource: %v", err)
}
tp := trace.NewTracerProvider(
trace.WithBatcher(exp),
trace.WithResource(res),
)
otel.SetTracerProvider(tp)
return func() {
err := tp.Shutdown(ctx)
if err != nil {
log.Printf("error shutting down tracer provider: %v", err)
}
}
}
Com o rastreamento configurado, só precisamos inserir spans no código para rastrear chamadas. Por exemplo, se quisermos medir as chamadas ao banco de dados (já que esse é geralmente o primeiro lugar que olhamos para problemas de desempenho), podemos escrever algo assim:
tracer := otel.Tracer("auth-service")
ctx, span := tracer.Start(ctx, "GetUserInfo")
defer span.End()
tracedLogger := logging.AddTraceContextToLogger(ctx)
tracedLogger.Info("find user info",
zap.String("operation", "find user"),
zap.String("username", username),
)
user, err := s.userRepo.GetUserInfo(ctx, username)
if err != nil {
s.logger.Error(errNotFound)
span.RecordError(err)
span.SetStatus(otelCodes.Error, "Failed to fetch user info")
return nil, status.Errorf(grpcCodes.NotFound, errNotFound, err)
}
span.SetStatus(otelCodes.Ok, "User info retrieved successfully")
Temos rastreamento na camada de serviço — ótimo! Mas podemos ir ainda mais fundo, instrumentando a camada do banco de dados:
func (r *UserRepository) GetUserInfo(ctx context.Context, username string) (*models.User, error) {
tracer := otel.Tracer("auth-service")
ctx, span := tracer.Start(ctx, "UserRepository.GetUserInfo",
trace.WithAttributes(
attribute.String("db.statement", query),
attribute.String("db.user", username),
),
)
defer span.End()
var user models.User
// Some code that queries the DB...
// err := doDatabaseCall()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "Failed to execute query")
return nil, fmt.Errorf("failed to fetch user info: %w", err)
}
span.SetStatus(codes.Ok, "Query executed successfully")
return &user, nil
}
Agora, temos uma visão completa da jornada da solicitação. Acesse a interface do Jaeger, consulte os últimos 20 rastreamentos sob auth-service
, e você verá todos os spans e como eles se conectam em um só lugar.
Agora, tudo está visível. Se você precisar, pode incluir a consulta completa nas tags. No entanto, tenha em mente que você não deve sobrecarregar sua telemetria — adicione dados de forma deliberada. Estou apenas demonstrando o que é possível, mas incluir a consulta completa, dessa forma, não é algo que eu geralmente recomendaria.
Cliente-servidor gRPC
Se você quiser ver um rastreamento que abrange dois serviços gRPC, é bastante simples. Tudo o que você precisa fazer é adicionar os interceptores prontos para uso da biblioteca. Por exemplo, no lado do servidor:
server := grpc.NewServer(
grpc.StatsHandler(otelgrpc.NewServerHandler()),
)
pb.RegisterAuthServiceServer(server, authService)
No lado do cliente, o código é igualmente curto:
shutdown := tracing.InitTracer(ctx)
defer shutdown()
conn, err := grpc.Dial(
"auth-service:50051",
grpc.WithInsecure(),
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
)
if err != nil {
logger.Fatal("error", zap.Error(err))
}
É isso! Certifique-se de que seus exportadores estão configurados corretamente e você verá um único ID de Rastreio registrado nesses serviços quando o cliente chamar o servidor.
Tratamento de Eventos CDC e Rastreio
Quer lidar com eventos do CDC também? Uma abordagem simples é incorporar o ID de Rastreio no objeto que o MongoDB armazena. Dessa forma, quando o Debezium captura a mudança e a envia para o Kafka, o ID de Rastreio já faz parte do registro.
Por exemplo, se você estiver usando o MongoDB, pode fazer algo assim:
func (r *mongoPurchaseRepo) SavePurchase(ctx context.Context, purchase entity.Purchase) error {
span := r.handleTracing(ctx, purchase)
defer span.End()
// Insert the record into MongoDB, including the current span's Trace ID
_, err := r.collection.InsertOne(ctx, bson.M{
"_id": purchase.ID,
"user_id": purchase.UserID,
"username": purchase.Username,
"amount": purchase.Amount,
"currency": purchase.Currency,
"payment_method": purchase.PaymentMethod,
// ...
"trace_id": span.SpanContext().TraceID().String(),
})
return err
}
O Debezium então captura este objeto (incluindo trace_id
) e o envia para o Kafka. No lado do consumidor, você simplesmente analisa a mensagem recebida, extrai o trace_id
e o mescla no seu contexto de rastreamento:
// If we find a Trace ID in the payload, attach it to the context
newCtx := ctx
if traceID != "" {
log.Printf("Found Trace ID: %s", traceID)
newCtx = context.WithValue(ctx, "trace-id", traceID)
}
// Create a new span
tracer := otel.Tracer("purchase-processor")
newCtx, span := tracer.Start(newCtx, "handler.processPayload")
defer span.End()
if traceID != "" {
span.SetAttributes(
attribute.String("trace.id", traceID),
)
}
// Parse the "after" field into a Purchase struct...
var purchase model.Purchase
if err := mapstructure.Decode(afterDoc, &purchase); err != nil {
log.Printf("Failed to map 'after' payload to Purchase struct: %v", err)
return err
}
// If we find a Trace ID in the payload, attach it to the context
newCtx := ctx
if traceID != "" {
log.Printf("Found Trace ID: %s", traceID)
newCtx = context.WithValue(ctx, "trace-id", traceID)
}
// Create a new span
tracer := otel.Tracer("purchase-processor")
newCtx, span := tracer.Start(newCtx, "handler.processPayload")
defer span.End()
if traceID != "" {
span.SetAttributes(
attribute.String("trace.id", traceID),
)
}
// Parse the "after" field into a Purchase struct...
var purchase model.Purchase
if err := mapstructure.Decode(afterDoc, &purchase); err != nil {
log.Printf("Failed to map 'after' payload to Purchase struct: %v", err)
return err
}
Alternativa: Usando Cabeçalhos do Kafka
Às vezes, é mais fácil armazenar o ID de Rastreio nos cabeçalhos do Kafka em vez de no próprio payload. Para fluxos de trabalho de CDC, isso pode não estar disponível “pronto para uso” — o Debezium pode limitar o que é adicionado aos cabeçalhos. Mas se você controla o lado do produtor (ou se está usando um produtor padrão do Kafka), você pode fazer algo assim com o Sarama:
Injetando um ID de Rastreio nos Cabeçalhos
// saramaHeadersCarrier is a helper to set/get headers in a Sarama message.
type saramaHeadersCarrier *[]sarama.RecordHeader
func (c saramaHeadersCarrier) Get(key string) string {
for _, h := range *c {
if string(h.Key) == key {
return string(h.Value)
}
}
return ""
}
func (c saramaHeadersCarrier) Set(key string, value string) {
*c = append(*c, sarama.RecordHeader{
Key: []byte(key),
Value: []byte(value),
})
}
// Before sending a message to Kafka:
func produceMessageWithTraceID(ctx context.Context, producer sarama.SyncProducer, topic string, value []byte) error {
span := trace.SpanFromContext(ctx)
traceID := span.SpanContext().TraceID().String()
headers := make([]sarama.RecordHeader, 0)
carrier := saramaHeadersCarrier(&headers)
carrier.Set("trace-id", traceID)
msg := &sarama.ProducerMessage{
Topic: topic,
Value: sarama.ByteEncoder(value),
Headers: headers,
}
_, _, err := producer.SendMessage(msg)
return err
}
Extraindo um ID de Rastreio no Lado do Consumidor
for message := range claim.Messages() {
// Extract the trace ID from headers
var traceID string
for _, hdr := range message.Headers {
if string(hdr.Key) == "trace-id" {
traceID = string(hdr.Value)
}
}
// Now continue your normal tracing workflow
if traceID != "" {
log.Printf("Found Trace ID in headers: %s", traceID)
// Attach it to the context or create a new span with this info
}
}
Dependendo do seu caso de uso e como seu pipeline de CDC está configurado, você pode escolher a abordagem que funciona melhor:
- Incorporar o ID de Rastreio no registro do banco de dados para que flua naturalmente via CDC.
- Usar cabeçalhos do Kafka se você tiver mais controle sobre o lado do produtor ou quiser evitar inflar o payload da mensagem.
De qualquer forma, você pode manter seus rastros consistentes entre vários serviços — mesmo quando os eventos são processados de forma assíncrona via Kafka e Debezium.
Conclusão
Usar OpenTelemetry e Jaeger fornece rastros de requisição detalhados, ajudando você a identificar onde e por que ocorrem atrasos em sistemas distribuídos.
Adicionar o Prometheus completa o quadro com métricas — indicadores-chave de desempenho e estabilidade. Juntas, essas ferramentas formam uma pilha de observabilidade abrangente, possibilitando uma detecção e resolução de problemas mais rápidas, otimização de desempenho e confiabilidade geral do sistema.
Posso dizer que essa abordagem acelera significativamente a solução de problemas em um ambiente de microserviços e é uma das primeiras coisas que implementamos em nossos projetos.
Links
Source:
https://dzone.com/articles/control-services-otel-jaeger-prometheus