mirror of
https://github.com/Dvorinka/Productier.git
synced 2026-06-03 20:13:01 +00:00
264 lines
7.3 KiB
Go
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:])
|
|
}
|