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 = "" } 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:]) }