OpenTelemetry (OTel) es el estándar de facto para la observabilidad, proporcionando un modelo unificado para recolectar trazas, métricas y logs. En lugar de acoplar tu código a un proveedor específico como Datadog o Honeycomb, utilizas la API de OpenTelemetry para instrumentar tu aplicación; si mañana decides cambiar de proveedor, solo cambias el exporter en la configuración, no en la lógica de negocio.
Un trace (traza) es la historia completa de una solicitud a través de un sistema distribuido, compuesta por una serie de spans (segmentos). Cada span representa una unidad de trabajo (una consulta a la base de datos, una llamada HTTP, un cálculo pesado). Para que esto funcione, es imperativo que el context.Context de Go transporte la información de la traza actual (el trace context) a través de todas las llamadas de función y límites de red.
Cuando implementas esto, el SDK de Go se encarga de propagar estos metadatos. Si realizas una llamada HTTP utilizando un Transport instrumentado, el cliente inyectará los IDs de la traza en las cabeceras de la petición, permitiendo que el siguiente microservicio reconstruya la línea de tiempo de forma continua.
Si fallas en la propagación del contexto —por ejemplo, si creas un context.Background() en lugar de usar el contexto que viene en la petición— romperás la cadena de eventos. El resultado será una serie de “spans huérfanos” que parecen ser el inicio de trazas nuevas, haciendo que el rastreo distribuido sea inútil para entender el flujo completo de una petición.
package main
import (
"context"
"fmt"
"log"
"net/http"
"time"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
"go.opentelemetry.io/otel/trace"
)
// setupTracer configura el SDK para enviar trazas a la consola (stdout)
// en un escenario real, usarías un exportador OTLP para enviarlo a Jaeger o Honeycomb.
func setupTracer() (*sdktrace.TracerProvider, error) {
exporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint())
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithSampler(sdktrace.AlwaysSample()), // En producción usarías ParentBased(AlwaysSample)
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceNameKey.String("order-service"),
)),
)
otel.SetTracerProvider(tp)
return tp, nil
}
func main() {
tp, err := setupTracer()
if err != nil {
log.Fatal(err)
}
defer func() {
if err := tp.Shutdown(context.Background()); err != nil {
log.Printf("Error al cerrar el tracer provider: %v", err)
}
}()
// Creamos un tracer con un nombre identificador para este componente
tracer := otel.Tracer("order-handler")
// Implementamos un handler con instrumentación automática de OpenTelemetry
// otelhttp.NewHandler captura automáticamente el contexto de la petición HTTP
handler := otelhttp.NewHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extraemos el contexto que ya contiene el span de la petición HTTP
ctx := r.Context()
userID := "user_12345"
orderID := "order_987"
// Iniciamos un nuevo span hijo para la lógica de negocio
// Es vital usar el 'ctx' que viene del request para mantener la jerarquía
ctx, span := tracer.Start(ctx, "ProcessOrder")
defer span.End() // Aseguramos el cierre del span al finalizar la función
// Añadimos atributos para enriquecer la búsqueda en el backend de tracing
span.SetAttributes(
attribute.String("user.id", userID),
attribute.String("order.id", orderID),
)
// Simulamos una llamada a la base de datos
if err := queryDatabase(ctx, orderID); err != nil {
span.RecordError(err) // Registra el error en el span
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "Orden %s procesada con éxito", orderID)
}), "OrderHandler")
http.Handle("/process", handler)
log.Println("Servidor escuchando en :8080...")
log.Fatal(http.ListenAndServe(":8080", nil))
}
func queryDatabase(ctx context.Context, orderID string) error {
// Obtenemos el tracer desde el contexto actual
tracer := otel.Tracer("db-client")
// Creamos un span hijo para la consulta SQL
_, span := tracer.Start(ctx, "SQL: SELECT * FROM orders WHERE id = ?")
defer span.End()
// Simulamos latencia de red/DB
time.Sleep(150 * time.Millisecond)
// En un caso real, si la consulta fallara, usaríamos span.SetStatus
log.Printf("Consulta completada para order: %s", orderID)
return nil
}
Análisis del flujo
En el ejemplo, la instrumentación comienza con otelhttp.NewHandler. Este middleware es clave: intercepta la petición HTTP, crea un root span y lo inyecta en el http.Request a través del context.Context.
Cuando dentro del handler ejecutamos tracer.Start(ctx, "ProcessOrder"), el SDK detecta que el ctx pasado ya contiene un span activo. Por lo tanto, no crea un nuevo trace, sino un child span (span hijo), manteniendo la integridad de la traza. Fíjate en span.SetAttributes: esto añade metadatos indexables como user.id. En herramientas de observabilidad, esto permite filtrar instantáneamente todas las trazas de un usuario específico sin necesidad de buscar en los logs.
La función queryDatabase recibe el ctx y vuelve a llamar a tracer.Start. Gracias a la propagación en el contexto, el span de la base de datos se anida debajo de ProcessOrder, creando la estructura jerárquica que permite visualizar la latencia de cada paso. Finalmente, el uso de defer span.End() es fundamental para asegurar que el span se marque como finalizado incluso si la función retorna prematuramente por un error.
El error frecuente
Un error clásico que rompe la observabilidad es “perder” el contexto al llamar a funciones asíncronas o al iniciar procesos que no respetan la jerarquía del context.
// ERROR: El trace se rompe aquí
func BadHandler(w http.ResponseWriter, r *http.Request) {
// Usar context.Background() crea una nueva raíz de traza,
// perdiendo toda la conexión con la petición HTTP original.
ctx := context.Background()
go func(ctx context.Context) {
doWork(ctx) // Este trabajo aparecerá como una traza totalmente nueva e inconexa
}(ctx)
}
Si necesitas lanzar una goroutine, debes pasarle el contexto de la petición (r.Context()), pero ten cuidado: si la goroutine sobrevive a la petición HTTP, el contexto se cancelará cuando la petición termine. En esos casos, se suele usar un contexto derivado que mantenga los valores de la traza pero con un ciclo de vida extendido, o simplemente asegurar que el trabajo sea síncrono respecto al ciclo de vida de la petición si la trazabilidad es crítica.
N° 214