mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 14:07:31 +00:00
01788459f1
Scan only the first five non-empty segments into a fixed stack buffer and build one owned label, preserving numeric, UUID-like, token, query and unknown-name behaviour. Pin baseline compatibility, bounded allocations, concurrency and actual middleware metric cardinality. Contract-Neutral: Preserve all HTTP metric labels and recording contracts; reduce only temporary route-normalization allocations. Change-source: pulse-maintainer
185 lines
4.1 KiB
Go
185 lines
4.1 KiB
Go
package api
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
)
|
|
|
|
var (
|
|
httpMetricsOnce sync.Once
|
|
|
|
apiRequestDuration *prometheus.HistogramVec
|
|
apiRequestTotal *prometheus.CounterVec
|
|
apiRequestErrors *prometheus.CounterVec
|
|
deprecatedAPIUsage *prometheus.CounterVec
|
|
)
|
|
|
|
func initHTTPMetrics() {
|
|
apiRequestDuration = prometheus.NewHistogramVec(
|
|
prometheus.HistogramOpts{
|
|
Namespace: "pulse",
|
|
Subsystem: "http",
|
|
Name: "request_duration_seconds",
|
|
Help: "HTTP request duration observed at the API layer.",
|
|
Buckets: []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
|
|
},
|
|
[]string{"method", "route", "status"},
|
|
)
|
|
|
|
apiRequestTotal = prometheus.NewCounterVec(
|
|
prometheus.CounterOpts{
|
|
Namespace: "pulse",
|
|
Subsystem: "http",
|
|
Name: "requests_total",
|
|
Help: "Total number of HTTP requests handled by the API.",
|
|
},
|
|
[]string{"method", "route", "status"},
|
|
)
|
|
|
|
apiRequestErrors = prometheus.NewCounterVec(
|
|
prometheus.CounterOpts{
|
|
Namespace: "pulse",
|
|
Subsystem: "http",
|
|
Name: "request_errors_total",
|
|
Help: "Total number of HTTP errors surfaced to clients.",
|
|
},
|
|
[]string{"method", "route", "status_class"},
|
|
)
|
|
|
|
deprecatedAPIUsage = prometheus.NewCounterVec(
|
|
prometheus.CounterOpts{
|
|
Namespace: "pulse",
|
|
Subsystem: "http",
|
|
Name: "deprecated_api_usage_total",
|
|
Help: "Total number of requests routed through deprecated API compatibility paths.",
|
|
},
|
|
[]string{"feature", "route"},
|
|
)
|
|
|
|
prometheus.MustRegister(apiRequestDuration, apiRequestTotal, apiRequestErrors, deprecatedAPIUsage)
|
|
}
|
|
|
|
func recordAPIRequest(method, route string, status int, elapsed time.Duration) {
|
|
httpMetricsOnce.Do(initHTTPMetrics)
|
|
|
|
statusCode := strconv.Itoa(status)
|
|
|
|
apiRequestDuration.WithLabelValues(method, route, statusCode).Observe(elapsed.Seconds())
|
|
apiRequestTotal.WithLabelValues(method, route, statusCode).Inc()
|
|
|
|
if status >= 400 {
|
|
apiRequestErrors.WithLabelValues(method, route, classifyStatus(status)).Inc()
|
|
}
|
|
}
|
|
|
|
func recordDeprecatedAPIUsage(feature, route string) {
|
|
httpMetricsOnce.Do(initHTTPMetrics)
|
|
deprecatedAPIUsage.WithLabelValues(feature, route).Inc()
|
|
}
|
|
|
|
func classifyStatus(status int) string {
|
|
switch {
|
|
case status >= 500:
|
|
return "server_error"
|
|
case status >= 400:
|
|
return "client_error"
|
|
default:
|
|
return "none"
|
|
}
|
|
}
|
|
|
|
func normalizeRoute(path string) string {
|
|
if path == "" || path == "/" {
|
|
return "/"
|
|
}
|
|
|
|
// Strip query parameters.
|
|
if idx := strings.IndexByte(path, '?'); idx >= 0 {
|
|
path = path[:idx]
|
|
}
|
|
|
|
// Only five non-empty segments enter the label. Keep those on the stack
|
|
// instead of allocating slices for the entire (possibly much longer) path.
|
|
var segments [5]string
|
|
count, size := 0, 0
|
|
for len(path) > 0 && count < len(segments) {
|
|
seg := path
|
|
if idx := strings.IndexByte(path, '/'); idx >= 0 {
|
|
seg, path = path[:idx], path[idx+1:]
|
|
} else {
|
|
path = ""
|
|
}
|
|
if seg == "" {
|
|
continue
|
|
}
|
|
seg = normalizeSegment(seg)
|
|
segments[count] = seg
|
|
size += 1 + len(seg)
|
|
count++
|
|
}
|
|
|
|
if count == 0 {
|
|
return "/"
|
|
}
|
|
|
|
// One owned string, including the leading slash. Returning a path prefix
|
|
// would retain discarded query/token bytes in long-lived metric labels.
|
|
var route strings.Builder
|
|
route.Grow(size)
|
|
for _, seg := range segments[:count] {
|
|
route.WriteByte('/')
|
|
route.WriteString(seg)
|
|
}
|
|
return route.String()
|
|
}
|
|
|
|
func normalizeSegment(seg string) string {
|
|
if isNumeric(seg) {
|
|
return ":id"
|
|
}
|
|
if looksLikeUUID(seg) {
|
|
return ":uuid"
|
|
}
|
|
if len(seg) > 32 {
|
|
return ":token"
|
|
}
|
|
return seg
|
|
}
|
|
|
|
func isNumeric(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
// These labels accept ASCII digits only, so decoding UTF-8 is unnecessary.
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] < '0' || s[i] > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func looksLikeUUID(s string) bool {
|
|
if len(s) != 36 {
|
|
return false
|
|
}
|
|
for i := 0; i < len(s); i++ {
|
|
r := s[i]
|
|
switch {
|
|
case r == '-':
|
|
if i != 8 && i != 13 && i != 18 && i != 23 {
|
|
return false
|
|
}
|
|
case (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F'):
|
|
continue
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|