feat: add transport-neutral request metrics

This commit is contained in:
2026-09-22 10:00:02 +08:00
parent 5ab906098f
commit ef98d1dd8d
5 changed files with 189 additions and 0 deletions

106
metrics/metrics.go Normal file
View File

@@ -0,0 +1,106 @@
// Package metrics provides a small transport-neutral request metrics collector.
// It is intentionally in-memory and dependency-free; applications can expose
// Snapshot through any route or export it to their monitoring system.
package metrics
import (
"sync/atomic"
"time"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
// Snapshot is a point-in-time view of request metrics collected by Metrics.
type Snapshot struct {
Requests uint64 `json:"requests"`
Errors uint64 `json:"errors"`
InFlight int64 `json:"inFlight"`
TotalDurationMs float64 `json:"totalDurationMs"`
StatusCounts map[string]uint64 `json:"statusCounts"`
}
// Metrics collects request counts, status counts, in-flight requests and
// cumulative handler time. A zero Metrics value is ready for use.
type Metrics struct {
requests atomic.Uint64
errors atomic.Uint64
inFlight atomic.Int64
durationNs atomic.Int64
statuses [600]atomic.Uint64
}
// New creates an empty metrics collector.
func New() *Metrics { return &Metrics{} }
// Middleware records every request that reaches the middleware chain. Errors
// include returned handler errors and responses with a status of 400 or greater.
func (m *Metrics) Middleware() httpx.Middleware {
return func(next httpx.Handler) httpx.Handler {
return func(ctx *httpx.Context) error {
if m == nil {
return next(ctx)
}
m.inFlight.Add(1)
started := time.Now()
err := next(ctx)
status := ctx.Status()
if err != nil && !ctx.Responded() {
status = apierrors.From(err).Status
}
m.requests.Add(1)
if err != nil || status >= 400 {
m.errors.Add(1)
}
if status >= 0 && status < len(m.statuses) {
m.statuses[status].Add(1)
}
m.durationNs.Add(time.Since(started).Nanoseconds())
m.inFlight.Add(-1)
return err
}
}
}
// Handler exposes the current snapshot through the standard GoOmni response
// envelope. Register it explicitly, for example app.GET("/metrics", m.Handler()).
func (m *Metrics) Handler() httpx.Handler {
return func(ctx *httpx.Context) error {
return ctx.OK(m.Snapshot())
}
}
// Snapshot returns a consistent-enough point-in-time view for operational
// reporting. Counters may advance while the snapshot is being assembled.
func (m *Metrics) Snapshot() Snapshot {
if m == nil {
return Snapshot{StatusCounts: map[string]uint64{}}
}
statuses := make(map[string]uint64)
for status := 100; status < len(m.statuses); status++ {
if count := m.statuses[status].Load(); count > 0 {
statuses[itoa(status)] = count
}
}
return Snapshot{
Requests: m.requests.Load(),
Errors: m.errors.Load(),
InFlight: m.inFlight.Load(),
TotalDurationMs: float64(m.durationNs.Load()) / float64(time.Millisecond),
StatusCounts: statuses,
}
}
func itoa(value int) string {
if value == 0 {
return "0"
}
var digits [3]byte
index := len(digits)
for value > 0 {
index--
digits[index] = byte('0' + value%10)
value /= 10
}
return string(digits[index:])
}