mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-12 00:06:27 +00:00
feat(metrics): bound HTTP path labels, add reset, custom push endpoint and JSON pull
- normalize the HTTP path label (ServeMux pattern, custom normalizer, ID collapsing) and cap distinct values via HTTPMaxPaths (default 1024) - add Reset, PushAndReset, ResetHandler and reset-on-push options - add POST push to a custom endpoint (text or json) with optional reset - add JSONHandler for JSON pull - honour Config.Enabled; log Pushgateway push failures
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultHTTPMaxPaths is the default cap on distinct values of the "path" label.
|
||||
defaultHTTPMaxPaths = 1024
|
||||
|
||||
// overflowPathLabel is used once the cap on distinct path labels is reached.
|
||||
overflowPathLabel = "other"
|
||||
)
|
||||
|
||||
// routeLabel returns the low-cardinality path label for a request, preferring
|
||||
// (in order): the custom normalizer, the matched ServeMux pattern, and finally
|
||||
// the generic normalization of the raw URL path.
|
||||
func routeLabel(r *http.Request, custom func(*http.Request) string) string {
|
||||
if custom != nil {
|
||||
if p := custom(r); p != "" {
|
||||
return p
|
||||
}
|
||||
}
|
||||
if r.Pattern != "" {
|
||||
return stripPatternMethod(r.Pattern)
|
||||
}
|
||||
return NormalizePath(r.URL.Path)
|
||||
}
|
||||
|
||||
// stripPatternMethod removes the optional "METHOD " prefix (and host) from a
|
||||
// Go 1.22+ ServeMux pattern, e.g. "GET /users/{id}" -> "/users/{id}".
|
||||
func stripPatternMethod(pattern string) string {
|
||||
if i := strings.IndexByte(pattern, ' '); i >= 0 {
|
||||
pattern = strings.TrimLeft(pattern[i+1:], " ")
|
||||
}
|
||||
if i := strings.IndexByte(pattern, '/'); i > 0 {
|
||||
pattern = pattern[i:] // drop host part
|
||||
}
|
||||
return pattern
|
||||
}
|
||||
|
||||
// NormalizePath replaces dynamic-looking path segments (numeric IDs, UUIDs,
|
||||
// long hex strings and other long opaque tokens) with ":id" so that
|
||||
// /users/123 and /users/456 share one label value.
|
||||
func NormalizePath(path string) string {
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.Contains(path, "/") {
|
||||
return path
|
||||
}
|
||||
segs := strings.Split(path, "/")
|
||||
for i, s := range segs {
|
||||
if isDynamicSegment(s) {
|
||||
segs[i] = ":id"
|
||||
}
|
||||
}
|
||||
return strings.Join(segs, "/")
|
||||
}
|
||||
|
||||
func isDynamicSegment(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
if allDigits(s) {
|
||||
return true
|
||||
}
|
||||
if isUUID(s) {
|
||||
return true
|
||||
}
|
||||
// Long hex strings (hashes, object IDs)
|
||||
if len(s) >= 16 && allHex(s) {
|
||||
return true
|
||||
}
|
||||
// Long opaque tokens containing digits (base64/ULID-like)
|
||||
if len(s) >= 24 && hasDigit(s) && !strings.ContainsAny(s, ".") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func allDigits(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hasDigit(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] >= '0' && s[i] <= '9' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func allHex(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if !isHexByte(c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isUUID(s string) bool {
|
||||
if len(s) != 36 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch i {
|
||||
case 8, 13, 18, 23:
|
||||
if c != '-' {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if !isHexByte(c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// pathLimiter bounds the number of distinct path label values. Once the cap is
|
||||
// reached, unseen paths are reported as "other".
|
||||
type pathLimiter struct {
|
||||
mu sync.RWMutex
|
||||
max int // <= 0 disables the cap
|
||||
seen map[string]struct{}
|
||||
}
|
||||
|
||||
func newPathLimiter(limit int) *pathLimiter {
|
||||
return &pathLimiter{max: limit, seen: make(map[string]struct{})}
|
||||
}
|
||||
|
||||
func (l *pathLimiter) label(path string) string {
|
||||
if l.max <= 0 {
|
||||
return path
|
||||
}
|
||||
l.mu.RLock()
|
||||
_, ok := l.seen[path]
|
||||
l.mu.RUnlock()
|
||||
if ok {
|
||||
return path
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if _, ok := l.seen[path]; ok {
|
||||
return path
|
||||
}
|
||||
if len(l.seen) >= l.max {
|
||||
return overflowPathLabel
|
||||
}
|
||||
l.seen[path] = struct{}{}
|
||||
return path
|
||||
}
|
||||
|
||||
func (l *pathLimiter) reset() {
|
||||
l.mu.Lock()
|
||||
l.seen = make(map[string]struct{})
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func isHexByte(c byte) bool {
|
||||
return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'
|
||||
}
|
||||
Reference in New Issue
Block a user