Files
Tomas Dvorak 3cb40adb23 first commit
2026-04-10 12:04:09 +02:00

264 lines
7.3 KiB
Go

package httpapi
import (
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
)
type routeMetricSnapshot struct {
Method string `json:"method"`
Path string `json:"path"`
Status int `json:"status"`
Count uint64 `json:"count"`
AvgLatencyMs float64 `json:"avgLatencyMs"`
MaxLatencyMs float64 `json:"maxLatencyMs"`
LastSeenAt string `json:"lastSeenAt"`
}
type metricsSnapshot struct {
GeneratedAt string `json:"generatedAt"`
UptimeSeconds int64 `json:"uptimeSeconds"`
RequestsTotal uint64 `json:"requestsTotal"`
StatusClassTotals map[string]uint64 `json:"statusClassTotals"`
Routes []routeMetricSnapshot `json:"routes"`
}
type routeMetricBucket struct {
Method string
Path string
Status int
Count uint64
TotalLatencyNanos float64
MaxLatencyNanos float64
LastSeenAt time.Time
}
type requestMetrics struct {
startedAt time.Time
requestsTotal uint64
status2xxTotal uint64
status3xxTotal uint64
status4xxTotal uint64
status5xxTotal uint64
statusOther uint64
mu sync.RWMutex
buckets map[string]*routeMetricBucket
}
func newRequestMetrics() *requestMetrics {
return &requestMetrics{
startedAt: time.Now().UTC(),
buckets: make(map[string]*routeMetricBucket),
}
}
func (m *requestMetrics) observe(method, path string, status int, latency time.Duration) {
if m == nil {
return
}
if path == "" {
path = "<unmatched>"
}
now := time.Now().UTC()
latencyNanos := float64(latency.Nanoseconds())
key := method + " " + path + " " + itoa(status)
m.mu.Lock()
defer m.mu.Unlock()
m.requestsTotal++
switch {
case status >= 200 && status < 300:
m.status2xxTotal++
case status >= 300 && status < 400:
m.status3xxTotal++
case status >= 400 && status < 500:
m.status4xxTotal++
case status >= 500 && status < 600:
m.status5xxTotal++
default:
m.statusOther++
}
bucket, exists := m.buckets[key]
if !exists {
bucket = &routeMetricBucket{
Method: method,
Path: path,
Status: status,
Count: 1,
TotalLatencyNanos: latencyNanos,
MaxLatencyNanos: latencyNanos,
LastSeenAt: now,
}
m.buckets[key] = bucket
return
}
bucket.Count++
bucket.TotalLatencyNanos += latencyNanos
if latencyNanos > bucket.MaxLatencyNanos {
bucket.MaxLatencyNanos = latencyNanos
}
bucket.LastSeenAt = now
}
func (m *requestMetrics) snapshot() metricsSnapshot {
if m == nil {
return metricsSnapshot{
GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
StatusClassTotals: map[string]uint64{},
Routes: []routeMetricSnapshot{},
}
}
m.mu.RLock()
defer m.mu.RUnlock()
routes := make([]routeMetricSnapshot, 0, len(m.buckets))
for _, bucket := range m.buckets {
avgMs := 0.0
if bucket.Count > 0 {
avgMs = (bucket.TotalLatencyNanos / float64(bucket.Count)) / float64(time.Millisecond)
}
routes = append(routes, routeMetricSnapshot{
Method: bucket.Method,
Path: bucket.Path,
Status: bucket.Status,
Count: bucket.Count,
AvgLatencyMs: avgMs,
MaxLatencyMs: bucket.MaxLatencyNanos / float64(time.Millisecond),
LastSeenAt: bucket.LastSeenAt.Format(time.RFC3339Nano),
})
}
sort.Slice(routes, func(i, j int) bool {
if routes[i].Method != routes[j].Method {
return routes[i].Method < routes[j].Method
}
if routes[i].Path != routes[j].Path {
return routes[i].Path < routes[j].Path
}
return routes[i].Status < routes[j].Status
})
return metricsSnapshot{
GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
UptimeSeconds: int64(time.Since(m.startedAt).Seconds()),
RequestsTotal: m.requestsTotal,
StatusClassTotals: map[string]uint64{
"2xx": m.status2xxTotal,
"3xx": m.status3xxTotal,
"4xx": m.status4xxTotal,
"5xx": m.status5xxTotal,
"other": m.statusOther,
},
Routes: routes,
}
}
func requestMetricsMiddleware(metrics *requestMetrics) gin.HandlerFunc {
return func(c *gin.Context) {
startedAt := time.Now()
c.Next()
path := c.FullPath()
if path == "" {
path = c.Request.URL.Path
}
if path == "/v1/metrics" || path == "/v1/metrics/prometheus" {
return
}
metrics.observe(c.Request.Method, path, c.Writer.Status(), time.Since(startedAt))
}
}
func (m *requestMetrics) snapshotPrometheus() string {
snapshot := m.snapshot()
var builder strings.Builder
builder.WriteString("# HELP productier_http_uptime_seconds Process uptime in seconds.\n")
builder.WriteString("# TYPE productier_http_uptime_seconds gauge\n")
builder.WriteString("productier_http_uptime_seconds ")
builder.WriteString(strconv.FormatInt(snapshot.UptimeSeconds, 10))
builder.WriteByte('\n')
builder.WriteString("# HELP productier_http_requests_total Total HTTP requests by status class.\n")
builder.WriteString("# TYPE productier_http_requests_total counter\n")
statusClasses := []string{"2xx", "3xx", "4xx", "5xx", "other"}
for _, statusClass := range statusClasses {
builder.WriteString(`productier_http_requests_total{status_class="`)
builder.WriteString(escapePrometheusLabelValue(statusClass))
builder.WriteString(`"} `)
builder.WriteString(strconv.FormatUint(snapshot.StatusClassTotals[statusClass], 10))
builder.WriteByte('\n')
}
builder.WriteString("# HELP productier_http_requests_route_total Total HTTP requests by route and status code.\n")
builder.WriteString("# TYPE productier_http_requests_route_total counter\n")
builder.WriteString("# HELP productier_http_request_latency_avg_ms Average request latency in milliseconds by route and status code.\n")
builder.WriteString("# TYPE productier_http_request_latency_avg_ms gauge\n")
builder.WriteString("# HELP productier_http_request_latency_max_ms Max request latency in milliseconds by route and status code.\n")
builder.WriteString("# TYPE productier_http_request_latency_max_ms gauge\n")
for _, route := range snapshot.Routes {
labels := `method="` + escapePrometheusLabelValue(route.Method) +
`",path="` + escapePrometheusLabelValue(route.Path) +
`",status="` + strconv.Itoa(route.Status) + `"`
builder.WriteString("productier_http_requests_route_total{")
builder.WriteString(labels)
builder.WriteString("} ")
builder.WriteString(strconv.FormatUint(route.Count, 10))
builder.WriteByte('\n')
builder.WriteString("productier_http_request_latency_avg_ms{")
builder.WriteString(labels)
builder.WriteString("} ")
builder.WriteString(strconv.FormatFloat(route.AvgLatencyMs, 'f', 3, 64))
builder.WriteByte('\n')
builder.WriteString("productier_http_request_latency_max_ms{")
builder.WriteString(labels)
builder.WriteString("} ")
builder.WriteString(strconv.FormatFloat(route.MaxLatencyMs, 'f', 3, 64))
builder.WriteByte('\n')
}
return builder.String()
}
func escapePrometheusLabelValue(value string) string {
escaped := strings.ReplaceAll(value, `\`, `\\`)
escaped = strings.ReplaceAll(escaped, "\n", `\n`)
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
return escaped
}
func itoa(value int) string {
if value == 0 {
return "0"
}
isNegative := value < 0
if isNegative {
value = -value
}
var digits [20]byte
index := len(digits)
for value > 0 {
index--
digits[index] = byte('0' + (value % 10))
value /= 10
}
if isNegative {
index--
digits[index] = '-'
}
return string(digits[index:])
}