// 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) } }