// Package metrics exposes Prometheus instrumentation for the HTTP API // and the realtime hub. package metrics import ( "bufio" "errors" "net" "net/http" "strconv" "strings" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" ) var ( requestsTotal = prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "messenger_http_requests_total", Help: "HTTP requests handled, partitioned by method, normalized path and status.", }, []string{"method", "path", "status"}, ) requestDuration = prometheus.NewHistogramVec( prometheus.HistogramOpts{ Name: "messenger_http_request_duration_seconds", Help: "HTTP request latency.", Buckets: prometheus.DefBuckets, }, []string{"method", "path"}, ) wsConnections = prometheus.NewGauge( prometheus.GaugeOpts{ Name: "messenger_ws_connections", Help: "Current number of open WebSocket connections.", }, ) buildInfo = prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "messenger_build_info", Help: "Build information.", }, []string{"version"}, ) ) // Init registers all custom metrics. The default registry already carries // the standard Go and process collectors. func Init(version string) { prometheus.MustRegister(requestsTotal, requestDuration, wsConnections, buildInfo) buildInfo.WithLabelValues(version).Set(1) } // Handler returns the Prometheus scrape endpoint. func Handler() http.Handler { return promhttp.Handler() } // RecordWS increments or decrements the WebSocket connection gauge. func RecordWS(delta float64) { wsConnections.Add(delta) } // Middleware instruments every request with counters and a latency histogram. func Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() rec := &recorder{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(rec, r) path := NormalizePath(r.URL.Path) requestsTotal.WithLabelValues(r.Method, path, strconv.Itoa(rec.status)).Inc() requestDuration.WithLabelValues(r.Method, path).Observe(time.Since(start).Seconds()) }) } type recorder struct { http.ResponseWriter status int } func (r *recorder) WriteHeader(code int) { r.status = code r.ResponseWriter.WriteHeader(code) } // Hijack forwards the WebSocket upgrade to the underlying writer. Without // this, wrapping the mux breaks every WebSocket handshake. func (r *recorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { hj, ok := r.ResponseWriter.(http.Hijacker) if !ok { return nil, nil, errors.New("metrics: response writer does not support hijacking") } return hj.Hijack() } // Flush forwards periodic flush calls for streaming responses. func (r *recorder) Flush() { if f, ok := r.ResponseWriter.(http.Flusher); ok { f.Flush() } } // NormalizePath replaces numeric path segments with a placeholder so that // per-route cardinality stays bounded regardless of resource IDs. func NormalizePath(p string) string { parts := strings.Split(p, "/") for i, part := range parts { if part != "" && isNumeric(part) { parts[i] = "{id}" } } return strings.Join(parts, "/") } func isNumeric(s string) bool { if s == "" { return false } for _, r := range s { if r < '0' || r > '9' { return false } } return true }