Files
galaxy-game/backend/internal/server/middleware/metrics/metrics.go
T
2026-05-06 10:14:55 +03:00

111 lines
3.4 KiB
Go

// Package metrics emits per-request OpenTelemetry counters and histograms
// scoped by route group.
//
// The metric names are fixed by `backend/README.md` §15:
//
// - http_requests_total{group, method, route, status}
// - http_request_duration_seconds{group, method, route, status}
//
// One Middleware instance per route group keeps the `group` attribute stable
// across requests while allowing the gin router to share the same Meter.
package metrics
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
// Group identifies the route family that emits the metric. The set is closed
// and matches the prefixes registered by router.New.
type Group string
const (
// GroupRoot covers `/healthz`, `/readyz`, and unmatched routes.
GroupRoot Group = "root"
// GroupProbes covers the readiness/liveness probes when reported separately
// from other root-level traffic.
GroupProbes Group = "probes"
// GroupPublic covers `/api/v1/public/*` endpoints.
GroupPublic Group = "public"
// GroupUser covers `/api/v1/user/*` endpoints.
GroupUser Group = "user"
// GroupAdmin covers `/api/v1/admin/*` endpoints.
GroupAdmin Group = "admin"
// GroupInternal covers `/api/v1/internal/*` endpoints.
GroupInternal Group = "internal"
)
// Instruments holds the shared metric instruments used by every Group-scoped
// middleware. The instruments are constructed once per Meter; the
// per-middleware closure binds them to the right `group` attribute.
type Instruments struct {
requestsTotal metric.Int64Counter
requestDuration metric.Float64Histogram
}
// NewInstruments builds the shared metric instruments from meter. A nil meter
// returns nil instruments and disables metric emission.
func NewInstruments(meter metric.Meter) (*Instruments, error) {
if meter == nil {
return nil, nil
}
requestsTotal, err := meter.Int64Counter(
"http_requests_total",
metric.WithDescription("Number of HTTP requests served by the backend, partitioned by route group, method, route, and response status."),
metric.WithUnit("1"),
)
if err != nil {
return nil, err
}
requestDuration, err := meter.Float64Histogram(
"http_request_duration_seconds",
metric.WithDescription("Duration of HTTP requests served by the backend, partitioned by route group, method, route, and response status."),
metric.WithUnit("s"),
)
if err != nil {
return nil, err
}
return &Instruments{
requestsTotal: requestsTotal,
requestDuration: requestDuration,
}, nil
}
// Middleware returns a gin middleware that records request counters and
// duration histograms with the `group` attribute fixed to group. A nil
// instruments value yields a no-op middleware so that metric emission is
// strictly opt-in.
func Middleware(instruments *Instruments, group Group) gin.HandlerFunc {
if instruments == nil {
return func(c *gin.Context) { c.Next() }
}
return func(c *gin.Context) {
start := time.Now()
c.Next()
duration := time.Since(start)
route := c.FullPath()
if route == "" {
route = "unmatched"
}
attrs := metric.WithAttributes(
attribute.String("group", string(group)),
attribute.String("method", c.Request.Method),
attribute.String("route", route),
attribute.String("status", strconv.Itoa(c.Writer.Status())),
)
instruments.requestsTotal.Add(c.Request.Context(), 1, attrs)
instruments.requestDuration.Record(c.Request.Context(), duration.Seconds(), attrs)
}
}