mirror of
https://github.com/Dvorinka/Productier.git
synced 2026-06-04 12:33:01 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
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:])
|
||||
}
|
||||
Reference in New Issue
Block a user