Instrumentación de APIs con Prometheus en Go

Para observar el comportamiento de una aplicación bajo carga, no basta con logs; necesitamos series temporales. En Go, la librería prometheus/client_golang nos permite exponer el estado interno del sistema mediante cuatro tipos fundamentales de métricas: los Counter (valores que solo crecen, como el total de peticiones), los Gauge (valores que suben y bajan, como la memoria usada), los Histogram (que agrupan observaciones en cubetas o buckets para calcular percentiles) y los Summary (que calculan cuantiles con precisión en el lado del cliente).

Estas métricas funcionan mediante un modelo de pull: tu aplicación no envía los datos, sino que expone un endpoint (usualmente en /metrics) donde un servidor de Prometheus los recolecta. Cuando registras una métrica usando prometheus.MustRegister, la añades al registro global para que el handler pueda encontrarla. Si intentas registrar algo que ya existe, la aplicación lanzará un panic al arrancar, lo cual es preferible a tener una métrica que nunca se actualiza por un error de inicialización.

Para dimensionar estas métricas sin crear una explosión de series temporales, usamos labels. Un CounterVec nos permite, por ejemplo, usar la misma métrica de “peticiones totales” pero diferenciar por method, path y status_code. Sin embargo, si usas valores con alta cardinalidad (como un user_id o un order_id) como labels, saturarás la memoria del servidor de Prometheus y arruinarás el rendimiento de tu sistema de observabilidad.

Cuando implementes instrumentación en un middleware, el objetivo es capturar la latencia de la petición y el conteo de errores de forma transparente para la lógica de negocio.

package main

import (
	"fmt"
	"math/rand"
	"net/http"
	"time"

	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
	// httpRequestsTotal cuenta el número de peticiones HTTP.
	// Usamos CounterVec para segmentar por método, ruta y código de estado.
	httpRequestsTotal = prometheus.NewCounterVec(
		prometheus.CounterOpts{
			Name: "http_requests_total",
			Help: "Total de peticiones HTTP procesadas.",
		},
		[]string{"method", "path", "status"},
	)

	// httpDurationHistogram mide la latencia de las peticiones.
	// Los histogramas son preferibles para agregar latencias entre múltiples instancias.
	httpDurationHistogram = prometheus.NewHistogramVec(
		prometheus.HistogramOpts{
			Name:    "http_request_duration_seconds",
			Help:    "Latencia de las peticiones HTTP en segundos.",
			Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5},
		},
		[]string{"path"},
	)

	// activeConnections muestra cuántas peticiones se están procesando en este momento.
	activeConnections = prometheus.NewGauge(
		prometheus.GaugeOpts{
			Name: "http_active_connections",
			Help: "Número de conexiones HTTP activas actualmente.",
		},
	)
)

func init() {
	// MustRegister lanzará un panic si las métricas ya han sido registradas.
	// Es ideal para asegurar que la configuración de métricas sea única al arrancar.
	prometheus.MustRegister(httpRequestsTotal)
	prometheus.MustRegister(httpDurationHistogram)
	prometheus.MustRegister(activeConnections)
}

// metricsMiddleware intercepta las peticiones para medir latencia y conteo.
func metricsMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		activeConnections.Inc()
		defer activeConnections.Dec()

		// Creamos una respuesta que capture el status code para la métrica.
		rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
		
		next.ServeHTTP(rw, r)

		duration := time.Since(start).Seconds()
		path := r.URL.Path

		// Observar la latencia en el histograma.
		httpDurationHistogram.WithLabelValues(path).Observe(duration)
		
		// Incrementar el contador con las dimensiones necesarias.
		httpRequestsTotal.WithLabelValues(r.Method, path, fmt.Sprintf("%d", rw.statusCode)).Inc()
	})
}

// responseWriter es un wrapper para capturar el status code de la respuesta.
type responseWriter struct {
	http.ResponseWriter
	statusCode int
}

func (rw *responseWriter) writeHeader(code int) {
	rw.statusCode = code
	rw.ResponseWriter.WriteHeader(code)
}

// handlerSimulado representa una lógica de negocio que tarda algo de tiempo.
func handlerSimulado(w http.ResponseWriter, r *http.Request) {
	// Simula trabajo variable.
	time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond)
	
	if rand.Float32() < 0.1 { // 10% de probabilidad de error.
		w.WriteHeader(http.StatusInternalServerError)
		w.Write([]byte("error"))
		return
	}
	
	w.WriteHeader(http.StatusOK)
	w.Write([]byte("ok"))
}

func main() {
	mux := http.NewServeMux()
	
	// Aplicamos el middleware de instrumentación a nuestros handlers.
	mux.Handle("/api/data", metricsMiddleware(http.HandlerFunc(handlerSimulado)))
	mux.Handle("/api/status", metricsMiddleware(http.HandlerFunc(handlerSimulado)))

	// El endpoint /metrics es el que expondrá los datos para Prometheus.
	mux.Handle("/metrics", promhttp.Handler())

	server := &http.Server{
		Addr:         ":8080",
		Handler:      mux,
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
	}

	fmt.Println("Servidor corriendo en :8080. Métricas en /metrics")
	if err := server.ListenAndServe(); err != nil {
		panic(err)
	}
}

Desglose de la implementación

En el ejemplo anterior, el middleware metricsMiddleware actúa como el punto central de observabilidad. Al usar time.Since(start).Seconds(), calculamos la latencia justo antes de que la función termine. Esta duración se pasa a httpDurationHistogram.WithLabelValues(path).Observe(duration). Fíjate que el HistogramVec requiere que las etiquetas coincidan exactamente con las definidas en la variable global; si olvidas el path, el programa lanzará un error en tiempo de ejecución.

El uso de activeConnections.Inc() y defer activeConnections.Dec() garantiza que, sin importar si la petición termina con éxito o con un panic (siempre que el proceso no muera), el contador de conexiones activas se mantenga preciso. Esto es vital para detectar leaks de goroutines o conexiones colgadas.

Para las peticiones, usamos httpRequestsTotal.WithLabelValues(...). Aquí es donde implementamos el dimensionamiento. En lugar de crear una métrica para get_requests y otra para post_requests, usamos una sola métrica con la etiqueta method. Esto permite que en la consulta de Prometheus (PromQL) puedas hacer un sum(rate(http_requests_total[5m])) by (status) para obtener la tasa de errores global de forma inmediata.

El responseWriter es un patrón necesario porque la interfaz http.ResponseWriter estándar no permite leer el statusCode después de haber sido escrito. Sin este wrapper, solo podríamos medir si la petición fue exitosa o no, perdiendo la capacidad de distinguir entre un 404 Not Found y un 500 Internal Server Error en nuestras métricas.

El error frecuente

Un error crítico en sistemas distribuidos es la explosión de cardinalidad.

// ERROR FATAL: Nunca hagas esto
httpRequestsTotal.WithLabelValues(r.Method, r.URL.Path, r.URL.Query().Get("user_id")).Inc()

Si añades un user_id como etiqueta, y tienes un millón de usuarios, Prometheus tendrá que crear un millón de series temporales únicas en su base de datos. Esto destruirá el rendimiento de tu servidor de métricas, agotará su memoria y hará que tus dashboards tarden minutos en cargar. Las etiquetas deben ser de baja cardinalidad (valores conocidos y limitados como GET/POST, success/error, o us-east-1/us-west-2).

213

Dejar un comentario

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

Scroll al inicio