mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-07 13:56:29 +00:00
- 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
176 lines
3.5 KiB
Go
176 lines
3.5 KiB
Go
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'
|
|
}
|