OpenTelemetry avanzado en Go: Propagación, Sampling y Baggage

El rastreo distribuido es la capacidad de seguir el recorrido de una petición a través de múltiples microservicios, permitiendo visualizar la topología de llamadas en tiempo real. Este mecanismo se basa en la propagación de un SpanContext, que encapsula el TraceID (la identidad única de la operación completa) y el SpanID (la identidad de la unidad de trabajo actual). Funciona mediante la inyección de metadatos en los encabezados de transporte —siguiendo estándares como W3C Trace Context— que el siguiente servicio extrae para reconstruir la jerarquía de ejecución. Debes usarlo cuando tu arquitectura escala hacia microservicios y necesitas identificar exactamente qué componente está introduciendo latencia o fallando en una cadena de llamadas asíncronas o distribuidas. Si fallas al propagar el contexto o al configurar el Sampler, terminarás con trazas fragmentadas que no se conectan entre sí o, peor aún, con un consumo excesivo de CPU y ancho de banda debido a la recolección masiva de telemetría innecesaria en sistemas de alto tráfico.

package main

import (
	"context"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"time"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/baggage"
	"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
	"go.opentelemetry.io/otel/propagation"
	"go.opentelemetry.io/otel/sdk/resource"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
	semconv "go.opentelemetry.io/otel/semconv/v1.25.0"
	"go.opentelemetry.io/otel/trace"
)

// traceHandler es un handler de slog para correlacionar logs con spans.
type traceHandler struct {
	slog.Handler
}

func (h *traceHandler) Handle(ctx context.Context, r slog.Record) error {
	span := trace.SpanFrom(ctx)
	if span.SpanContext().IsValid() {
		r.AddAttrs(
			slog.String("trace_id", span.SpanContext().TraceID().String()),
			slog.String("span_id", span.SpanContext().SpanID().String()),
		)
	}
	return h.Handler.Handle(ctx, r)
}

func initTracer() (func(), error) {
	// Usamos un exporter de consola para el ejemplo; en producción usarías OTLP.
	exporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint())
	if err != nil {
		return nil, err
	}

	// ParentBased sampler: respeta la decisión del padre (crucial en sistemas distribuidos)
	// y aplica un muestreo probabilístico del 10% para no saturar el backend.
	sampler := sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1))

	res, err := resource.Merge(
		resource.Default(),
		resource.NewWithAttributes(
			semconv.SchemaURL,
			semconv.ServiceNameKey.String("order-service"),
		),
	)
	if err != nil {
		return nil, err
	}

	tp := sdktrace.NewTracerProvider(
		sdktrace.WithSampler(sampler),
		sdktrace.WithResource(res),
		sdktrace.WithBatcher(exporter),
	)
	otel.SetTracerProvider(tp)

	// Importante: Configurar el propagador W3C para que los headers de traceparent 
	// sean entendidos por otros servicios.
	otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
		propagation.TraceContext{},
		propagation.Baggage{},
	))

	return func() { _ = tp.Shutdown(context.Background()) }, nil
}

func main() {
	shutdown, err := initTracer()
	if err != nil {
		panic(err)
	}
	defer shutdown()

	// Configuración de logger correlacionado
	logger := slog.New(&traceHandler{slog.NewTextHandler(os.Stdout, nil)})
	slog.SetDefault(logger)

	// 1. Iniciar el flujo en el Service A
	ctx, span := otel.Tracer("main").Start(context.Background(), "ServiceA_ProcessOrder")
	defer span.End()

	// 2. Añadir Baggage (metadatos que viajan por toda la cadena)
	mem, _ := baggage.NewMember("tenant_id", "acme-corp-123")
	bag, _ := baggage.FromContext(ctx).SetMember(mem)
	ctx = baggage.ContextWithBaggage(ctx, bag)

	logger.InfoContext(ctx, "Iniciando proceso de orden")

	// Simulamos llamada HTTP a Service B
	if err := callServiceB(ctx); err != nil {
		logger.ErrorContext(ctx, "Error en la llamada", "error", err)
	}
}

func callServiceB(ctx context.Context) error {
	tracer := otel.Tracer("client")
	ctx, span := tracer.Start(ctx, "HTTP_Request_To_ServiceB")
	defer span.End()

	// Simulamos una petición HTTP inyectando el contexto en los headers
	req, _ := http.NewRequestWithContext(ctx, "GET", "/api/validate", nil)
	
	// Inyección manual del contexto (en producción usarías otelhttp.Get)
	otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))

	// Simulamos la recepción en el servidor Service B
	return simulateServiceB(req)
}

func simulateServiceB(req *http.Request) error {
	// Extracción del contexto desde los headers HTTP
	ctx := otel.GetTextMapPropagator().Extract(req.Context(), propagation.HeaderCarrier(req.Header))
	
	tracer := otel.Tracer("service-b")
	ctx, span := tracer.Start(ctx, "ServiceB_Validate")
	defer span.End()

	// Recuperar Baggage en el servicio destino
	bag := baggage.FromContext(ctx)
	tenantID := bag.Get("tenant_id")
	slog.InfoContext(ctx, "Validando para el cliente", "tenant_id", tenantID)

	return runDatabaseQuery(ctx)
}

func runDatabaseQuery(ctx context.Context) error {
	tracer := otel.Tracer("db")
	_, span := tracer.Start(ctx, "SQL_SELECT_ValidateOrder")
	defer span.End()

	// Simulamos latencia de base de datos
	time.Sleep(50 * time.Millisecond)
	return nil
}

Desglose técnico

En initTracer, la configuración del sdktrace.ParentBased es fundamental. Si utilizas un muestreo probabilístico simple (TraceIDRatioBased) sin el wrapper ParentBased, un servicio podría decidir no muestrear la traza, pero el siguiente servicio (al no tener el contexto de la decisión) podría intentar crear una traza nueva, rompiendo la continuidad del rastreo.

En la función callServiceB, aplicamos otel.GetTextMapPropagator().Inject. Esto toma el SpanContext actual y lo serializa en el encabezado traceparent (siguiendo el estándar W3C). El ServiceB hace lo opuesto con Extract. Sin este paso, cada servicio iniciaría un TraceID completamente nuevo, y perderías la capacidad de correlacionar el flujo de una sola petición a través de la red.

El uso de baggage.ContextWithBaggage permite propagar valores clave-valor (como el tenant_id) de forma transparente. A diferencia de los Attributes de un Span (que solo viven en ese span y sus hijos), el Baggage se inyecta en los headers HTTP y viaja por toda la infraestructura.

Para la correlación de logs, hemos implementado un traceHandler personalizado para slog. En ServiceB_Validate, cuando ejecutamos slog.InfoContext, el handler extrae automáticamente el trace_id y el span_id del contexto. Esto es vital en producción: cuando revisas un log de error, el trace_id te permite saltar directamente a la visualización de la traza completa en tu herramienta de observabilidad (como Jaeger o Tempo).

El error frecuente

Un error crítico que ocurre cuando el sistema crece es la pérdida de contexto por llamadas asíncronas o mala gestión de contextos.

// ERROR: Se rompe la trazabilidad
func processAsync(ctx context.Context) {
    go func() {
        // Usar context.Background() aquí corta la cadena de spans
        // de forma definitiva para la nueva goroutine.
        doWork(context.Background()) 
    }()
}

// CORRECTO: Se mantiene la jerarquía
func processAsync(ctx context.Context) {
    // Se debe derivar un nuevo contexto que mantenga el SpanContext
    go func(asyncCtx context.Context) {
        doWork(asyncCtx)
    }(ctx)
}

Si utilizas context.Background() en lugar de pasar el ctx original a una goroutine o a una llamada de base de datos, el SpanID del padre no se propagará. Verás una traza en el servicio principal, pero en los logs del servicio hijo o en las consultas SQL, el trace_id será uno nuevo, haciendo que sea imposible reconstruir el flujo completo de la petición original.

248

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio