Hablemos de una pregunta importante: ¿cómo monitoreamos nuestros servicios si algo sale mal?
Por un lado, contamos con Prometheus con alertas y Kibana para paneles y otras funciones útiles. También sabemos cómo recopilar registros: la pila ELK es nuestra solución preferida. Sin embargo, simplemente llevar un registro no siempre es suficiente: no proporciona una vista holística del recorrido de una solicitud a través de todo el ecosistema de componentes.
Puedes encontrar más información sobre ELK aquí.
Pero, ¿qué ocurre si queremos visualizar las solicitudes? ¿Y si necesitamos correlacionar las solicitudes que viajan entre sistemas? Esto se aplica tanto a microservicios como a monolitos; no importa cuántos servicios tengamos, sino cómo gestionamos su latencia.
De hecho, cada solicitud de usuario puede pasar por toda una cadena de servicios independientes, bases de datos, colas de mensajes y APIs externas.
En un entorno tan complejo, resulta extremadamente difícil precisar dónde se producen los retrasos, identificar qué parte de la cadena actúa como cuello de botella de rendimiento y encontrar rápidamente la causa raíz de las fallas cuando ocurren.
Para abordar estos desafíos de manera efectiva, necesitamos un sistema centralizado y consistente para recopilar datos de telemetría: trazas, métricas y registros. Aquí es donde OpenTelemetry y Jaeger entran en acción.
Veamos los conceptos básicos
Hay dos términos principales que debemos entender:
Identificador de traza
Un Identificador de traza es un identificador de 16 bytes, a menudo representado como una cadena hexadecimal de 32 caracteres. Se genera automáticamente al inicio de una traza y permanece igual en todos los segmentos creados por una solicitud particular. Esto facilita ver cómo una solicitud viaja a través de diferentes servicios o componentes en un sistema.
Identificador de segmento
Cada operación individual dentro de una traza recibe su propio Identificador de segmento, que generalmente es un valor de 64 bits generado aleatoriamente. Los segmentos comparten el mismo Identificador de traza, pero cada uno tiene un Identificador de segmento único, por lo que puedes identificar exactamente qué parte del flujo de trabajo representa cada segmento (como una consulta a una base de datos o una llamada a otro microservicio).
¿Cómo están relacionados?
Identificador de traza y Identificador de segmento se complementan mutuamente.
Cuando se inicia una solicitud, se genera un Identificador de traza y se pasa a todos los servicios involucrados. Cada servicio, a su vez, crea un segmento con un Identificador de segmento único vinculado al Identificador de traza, lo que te permite visualizar todo el ciclo de vida de la solicitud de principio a fin.
De acuerdo, entonces ¿por qué no usar solo Jaeger? ¿Por qué necesitamos OpenTelemetry (OTEL) y todas sus especificaciones? ¡Esa es una excelente pregunta! Vamos a analizarlo paso a paso.
Encuentra más sobre Jaeger aquí.
TL;DR
- Jaeger es un sistema para almacenar y visualizar trazas distribuidas. Recoge, almacena, busca y muestra datos que muestran cómo las solicitudes “viajan” a través de tus servicios.
- OpenTelemetry (OTEL) es un estándar (y un conjunto de bibliotecas) para recopilar datos de telemetría (trazas, métricas, registros) de tus aplicaciones e infraestructura. No está vinculado a ninguna herramienta de visualización o backend en particular.
En pocas palabras:
- OTEL es como un “lenguaje universal” y un conjunto de bibliotecas para la recopilación de telemetría.
- Jaeger es un backend y una interfaz de usuario para ver y analizar trazas distribuidas.
¿Por qué necesitamos OTEL si ya tenemos Jaeger?
1. Un único estándar para la recopilación
En el pasado, hubo proyectos como OpenTracing y OpenCensus. OpenTelemetry unifica estos enfoques para recopilar métricas y trazas en un único estándar universal.
2. Integración fácil
Escribes tu código en Go (o en otro lenguaje), añades bibliotecas de OTEL para la auto-inyección de interceptores y spans, y eso es todo. Después, no importa a dónde quieras enviar esos datos—Jaeger, Tempo, Zipkin, Datadog, un backend personalizado—OpenTelemetry se encarga de la conexión. Solo cambias el exportador.
3. No solo trazas
OpenTelemetry cubre trazas, pero también maneja métricas y registros. Terminas con un único conjunto de herramientas para todas tus necesidades de telemetría, no solo trazas.
4. Jaeger como backend
Jaeger es una excelente opción si estás principalmente interesado en la visualización de trazas distribuidas. Pero no proporciona la instrumentación entre lenguajes de forma predeterminada. OpenTelemetry, por otro lado, te ofrece una forma estandarizada de recopilar datos, y luego decides a dónde enviarlos (incluyendo Jaeger).
En la práctica, a menudo trabajan juntos:
Tu aplicación utiliza OpenTelemetry → se comunica a través del protocolo OTLP → va al OpenTelemetry Collector (HTTP o grpc) → se exporta a Jaeger para visualización.
Parte técnica
Diseño del sistema (un poco)
Esbozaremos rápidamente un par de servicios que harán lo siguiente:
- Servicio de compras – procesa un pago y lo registra en MongoDB
- CDC con Debezium – escucha los cambios en la tabla de MongoDB y los envía a Kafka
- Procesador de compras – consume el mensaje de Kafka y llama al Servicio de Autenticación para buscar el
user_id
para validación - Servicio de Autenticación – un servicio de usuario simple
En resumen:
- 3 servicios en Go
- Kafka
- CDC (Debezium)
- MongoDB
Parte del código
Comencemos con la infraestructura. Para unir todo en un solo sistema, crearemos un gran archivo de Docker Compose. Empezaremos configurando la telemetría.
Nota: Todo el código está disponible a través de un enlace al final del artículo, incluyendo la infraestructura.
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
También configuraremos el colector, el componente que recopila la telemetría.
Aquí, elegimos gRPC para la transferencia de datos, lo que significa que la comunicación ocurrirá a través de 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
Asegúrate de ajustar las direcciones según sea necesario, y habrás terminado con la configuración base.
Ya sabemos que OpenTelemetry (OTEL) utiliza dos conceptos clave — ID de Rastreo e ID de Segmento— que ayudan a rastrear y monitorear las solicitudes en sistemas distribuidos.
Implementando el Código
Ahora, veamos cómo hacer que esto funcione en tu código de Go. Necesitamos las siguientes importaciones:
"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"
Luego, agregamos una función para inicializar nuestro rastreador en main()
cuando la aplicación se 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)
}
}
}
Con el rastreo configurado, solo necesitamos colocar segmentos en el código para rastrear las llamadas. Por ejemplo, si queremos medir las llamadas a la base de datos (ya que generalmente es el primer lugar donde buscamos problemas de rendimiento), podemos escribir algo como esto:
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")
Tenemos el rastreo en la capa de servicios — ¡genial! Pero podemos ir aún más profundo, instrumentando la capa de la base de datos:
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
}
Ahora tenemos una vista completa del viaje de la solicitud. Dirígete a la interfaz de Jaeger, consulta las últimas 20 trazas bajo servicio-de-autenticación
, y verás todos los segmentos y cómo se conectan en un solo lugar.
Ahora, todo es visible. Si lo necesitas, puedes incluir la consulta completa en las etiquetas. Sin embargo, ten en cuenta que no debes sobrecargar tu telemetría, agrega datos deliberadamente. Simplemente estoy demostrando lo que es posible, pero incluir la consulta completa de esta manera no es algo que generalmente recomendaría.
Cliente-servidor gRPC
Si deseas ver un rastreo que abarca dos servicios gRPC, es bastante sencillo. Todo lo que necesitas es agregar los interceptores listos para usar de la biblioteca. Por ejemplo, en el lado del servidor:
server := grpc.NewServer(
grpc.StatsHandler(otelgrpc.NewServerHandler()),
)
pb.RegisterAuthServiceServer(server, authService)
En el lado del cliente, el código es igual de corto:
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))
}
¡Eso es todo! Asegúrate de que tus exportadores estén configurados correctamente y verás un solo ID de Rastreo registrado en estos servicios cuando el cliente llama al servidor.
Manejo de Eventos CDC y Rastreo
¿Quieres manejar también eventos del CDC? Un enfoque simple es incrustar el ID de Rastreo en el objeto que MongoDB almacena. De esta manera, cuando Debezium captura el cambio y lo envía a Kafka, el ID de Rastreo ya es parte del registro.
Por ejemplo, si estás utilizando MongoDB, puedes hacer algo así:
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
}
Luego, Debezium recoge este objeto (incluyendo trace_id
) y lo envía a Kafka. En el lado del consumidor, simplemente parseas el mensaje entrante, extraes el trace_id
y lo fusionas en tu contexto de rastreo:
// 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: Uso de Cabeceras de Kafka
A veces, es más fácil almacenar el ID de traza en los encabezados de Kafka en lugar de en la carga útil en sí misma. Para flujos de trabajo de CDC, esto podría no estar disponible de forma predeterminada — Debezium puede limitar lo que se agrega a los encabezados. Pero si controlas el lado del productor (o si estás utilizando un productor estándar de Kafka), puedes hacer algo así con Sarama:
Inyectar un ID de traza en los encabezados
// 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
}
Extraer un ID de traza en el lado del 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
}
}
Dependiendo de tu caso de uso y de cómo esté configurado tu canalización de CDC, puedes elegir el enfoque que mejor funcione:
- Incrustar el ID de traza en el registro de la base de datos para que fluya naturalmente a través de CDC.
- Usar los encabezados de Kafka si tienes más control sobre el lado del productor o si deseas evitar inflar la carga del mensaje.
De cualquier manera, puedes mantener tus trazas consistentes en múltiples servicios, incluso cuando los eventos se procesan de forma asincrónica a través de Kafka y Debezium.
Conclusión
Utilizar OpenTelemetry y Jaeger proporciona trazas detalladas de solicitudes, lo que te ayuda a identificar dónde y por qué se producen retrasos en sistemas distribuidos.
Agregar Prometheus completa el cuadro con métricas — indicadores clave de rendimiento y estabilidad. Juntos, estas herramientas forman un completo conjunto de observabilidad, permitiendo una detección y resolución más rápidas de problemas, optimización de rendimiento y confiabilidad general del sistema.
Puedo decir que este enfoque acelera significativamente la resolución de problemas en un entorno de microservicios y es una de las primeras cosas que implementamos en nuestros proyectos.
Enlaces
Source:
https://dzone.com/articles/control-services-otel-jaeger-prometheus