Vamos discutir uma questão importante: como monitoramos nossos serviços se algo der errado?
Por um lado, temos o Prometheus com alertas e o Kibana para painéis e outras funcionalidades úteis. Também sabemos como coletar logs — o conjunto ELK é nossa solução padrão. No entanto, apenas o registro simples nem sempre é suficiente: ele não fornece uma visão holística da jornada de uma solicitação em todo o ecossistema de componentes.
Você pode encontrar mais informações sobre o ELK aqui.
Mas e se quisermos visualizar solicitações? E se precisarmos correlacionar solicitações que viajam 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 solicitaçã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 atrasos, identificar qual parte da cadeia atua como gargalo de desempenho e encontrar rapidamente a causa raiz das falhas quando ocorrem.
Para enfrentar esses desafios de forma eficaz, precisamos de um sistema centralizado e consistente para coletar dados de telemetria — traces, métricas e logs. É aqui que o OpenTelemetry e o Jaeger entram em ação.
Vamos Olhar para os Básicos
Existem dois termos principais que precisamos entender:
ID de Rastro
Um ID de Rastro é um identificador de 16 bytes, frequentemente representado como uma string hexadecimal de 32 caracteres. Ele é gerado automaticamente no início de um rastro e permanece o mesmo em todos os spans criados por uma solicitação específica. Isso facilita a visualização de como uma solicitação passa por diferentes serviços ou componentes em um sistema.
ID de Span
Cada operação individual dentro de um rastro recebe seu próprio ID de Span, que é tipicamente um valor de 64 bits gerado aleatoriamente. Os spans compartilham o mesmo ID de Rastro, mas cada um tem um ID de Span único, permitindo que você identifique exatamente qual parte do fluxo de trabalho cada span representa (como uma consulta ao banco de dados ou uma chamada para outro microserviço).
Como Eles Estão Relacionados?
ID de Rastro e ID de Span se complementam.
Quando uma solicitação é iniciada, um ID de Rastro é gerado e passado para todos os serviços envolvidos. Cada serviço, por sua vez, cria um span com um ID de Span único vinculado ao ID de Rastro, permitindo que você visualize todo o ciclo de vida da solicitação do início ao fim.
Ok, 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 detalhar passo a passo.
Saiba mais sobre o Jaeger aqui.
Resumindo
- Jaeger é um sistema para armazenar e visualizar rastreamentos distribuídos. Ele coleta, armazena, pesquisa e exibe dados que mostram como as solicitações “viajam” através de seus serviços.
- OpenTelemetry (OTEL) é um padrão (e um conjunto de bibliotecas) para coletar dados de telemetria (rastreamentos, métricas, logs) de suas aplicações e infraestrutura. Não está vinculado a nenhuma ferramenta de visualização ou backend específico.
Em resumo:
- OTEL é como uma “linguagem universal” e conjunto de bibliotecas para coleta de telemetria.
- Jaeger é um backend e interface de usuário para visualizar e analisar rastreamentos distribuídos.
Por que precisamos do OTEL se já temos o Jaeger?
1. Um Único Padrão para Coleta
No passado, existiam projetos como OpenTracing e OpenCensus. O OpenTelemetry unifica essas abordagens para coleta de métricas e rastreamentos em um único padrão universal.
2. Integração Fácil
Você escreve seu código em Go (ou outra linguagem), adiciona bibliotecas OTEL para autoinjetar interceptadores e spans, e pronto. Depois, não importa para onde você queira enviar esses dados—Jaeger, Tempo, Zipkin, Datadog, um backend personalizado—o OpenTelemetry cuida disso. Você só precisa trocar o exportador.
3. Não Apenas Rastreamentos
O OpenTelemetry cobre rastreamentos, 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 rastreamento.
4. Jaeger como um Backend
Jaeger é uma excelente escolha se você está principalmente interessado na visualização de rastreamento distribuído. Mas não fornece a instrumentação de linguagem cruzada por padrão. Por outro lado, o OpenTelemetry 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 → comunica via protocolo OTLP → vai para o Coletor OpenTelemetry (HTTP ou grpc) → exporta para o 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 – ouve as mudanças 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 pesquisar o
user_id
para validação - Serviço de Autenticação – um simples serviço de usuário
Em resumo:
- 3 serviços em 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 iremos configurar o coletor — o componente que reúne 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
Verifique se ajustou quaisquer endereços conforme necessário, e você concluiu a configuração básica.
Já sabemos que o OpenTelemetry (OTEL) utiliza dois conceitos-chave — ID de Rastreamento 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"
Em seguida, 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 colocar spans no código para rastrear chamadas. Por exemplo, se quisermos medir chamadas ao banco de dados (já que esse é geralmente o primeiro lugar que procuramos por 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 o 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. Vá para a interface do Jaeger, consulte as últimas 20 traces em auth-service
, e você verá todos os spans e como se conectam em um só lugar.
Agora, tudo está visível. Se precisar, pode incluir a consulta inteira nas tags. No entanto, lembre-se de não sobrecarregar sua telemetria — adicione dados de forma deliberada. Estou simplesmente demonstrando o que é possível, mas incluir a consulta completa dessa forma não é algo que eu geralmente recomendaria.
Cliente-Servidor gRPC
Se você deseja ver um rastreamento que abrange dois serviços gRPC, é bastante simples. Tudo que você precisa fazer é adicionar os interceptadores prontos da biblioteca. Por exemplo, do lado do servidor:
server := grpc.NewServer(
grpc.StatsHandler(otelgrpc.NewServerHandler()),
)
pb.RegisterAuthServiceServer(server, authService)
No lado do cliente, o código é tão curto quanto:
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 estejam configurados corretamente, e você verá um único ID de Rastreamento registrado entre esses serviços quando o cliente chama o servidor.
Manipulando Eventos do CDC e Rastreamento
Deseja lidar também com eventos do CDC? Uma abordagem simples é incorporar o ID de Rastreamento no objeto que o MongoDB armazena. Dessa forma, quando o Debezium captura a mudança e a envia para o Kafka, o ID de Rastreamento já faz parte do registro.
Por exemplo, se estiver usando o MongoDB, você 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 pega esse objeto (incluindo trace_id
) e o envia para o Kafka. Do lado do consumidor, basta analisar a mensagem recebida, extrair o trace_id
e mesclá-lo em 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 rastreamento nos cabeçalhos do Kafka em vez de no próprio payload. Para fluxos de trabalho CDC, isso pode não estar disponível prontamente — o Debezium pode limitar o que é adicionado aos cabeçalhos. Mas se você controla o lado do produtor (ou se está usando um produtor Kafka padrão), você pode fazer algo assim com o Sarama:
Inserindo um ID de Rastreamento 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 Rastreamento 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 de como seu pipeline CDC está configurado, você pode escolher a abordagem que funciona melhor:
- Incorpore o ID de Rastreamento no registro do banco de dados para que ele flua naturalmente via CDC.
- Use os cabeçalhos do Kafka se tiver mais controle sobre o lado do produtor ou se quiser evitar inflar a carga da mensagem.
De qualquer forma, você pode manter seus rastreamentos consistentes em vários serviços — mesmo quando os eventos são processados de forma assíncrona via Kafka e Debezium.
Conclusão
O uso do OpenTelemetry e do Jaeger fornece rastreamentos detalhados de solicitações, ajudando a identificar onde e por que ocorrem atrasos em sistemas distribuídos.
A adição do Prometheus completa o quadro com métricas — indicadores-chave de desempenho e estabilidade. Juntos, essas ferramentas formam um conjunto abrangente de observabilidade, permitindo uma detecção e resolução mais rápidas de problemas, 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 microsserviços e é uma das primeiras coisas que implementamos em nossos projetos.
Links
Source:
https://dzone.com/articles/control-services-otel-jaeger-prometheus