111 lines
3.4 KiB
Go
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)
|
|
}
|
|
}
|