Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
// Package metrics provides minimal Prometheus-style HTTP RED and sync counters.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Fixed latency buckets (seconds) for HTTP and sync histograms.
|
||||
var durationBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30}
|
||||
|
||||
type labelKey struct {
|
||||
a, b, c string
|
||||
}
|
||||
|
||||
type histogram struct {
|
||||
counts []uint64
|
||||
sum float64
|
||||
count uint64
|
||||
}
|
||||
|
||||
func newHistogram() *histogram {
|
||||
return &histogram{counts: make([]uint64, len(durationBuckets))}
|
||||
}
|
||||
|
||||
func (h *histogram) observe(seconds float64) {
|
||||
h.sum += seconds
|
||||
h.count++
|
||||
for i, bound := range durationBuckets {
|
||||
if seconds <= bound {
|
||||
h.counts[i]++
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type registry struct {
|
||||
mu sync.Mutex
|
||||
|
||||
httpRequests map[labelKey]uint64
|
||||
httpDuration map[labelKey]*histogram
|
||||
syncDuration map[string]*histogram
|
||||
syncFailures map[string]uint64
|
||||
}
|
||||
|
||||
var defaultRegistry = ®istry{
|
||||
httpRequests: make(map[labelKey]uint64),
|
||||
httpDuration: make(map[labelKey]*histogram),
|
||||
syncDuration: make(map[string]*histogram),
|
||||
syncFailures: make(map[string]uint64),
|
||||
}
|
||||
|
||||
// ObserveHTTP records one finished request (RED: rate via counter, errors via code, duration).
|
||||
func ObserveHTTP(method, path string, status int, d time.Duration) {
|
||||
if path == "" {
|
||||
path = "unmatched"
|
||||
}
|
||||
key := labelKey{method, strconv.Itoa(status), path}
|
||||
sec := d.Seconds()
|
||||
defaultRegistry.mu.Lock()
|
||||
defer defaultRegistry.mu.Unlock()
|
||||
defaultRegistry.httpRequests[key]++
|
||||
h := defaultRegistry.httpDuration[key]
|
||||
if h == nil {
|
||||
h = newHistogram()
|
||||
defaultRegistry.httpDuration[key] = h
|
||||
}
|
||||
h.observe(sec)
|
||||
}
|
||||
|
||||
// ObserveSync records sync job duration and increments failures when err != nil.
|
||||
func ObserveSync(kind string, err error, d time.Duration) {
|
||||
kind = strings.TrimSpace(kind)
|
||||
if kind == "" {
|
||||
kind = "unknown"
|
||||
}
|
||||
sec := d.Seconds()
|
||||
defaultRegistry.mu.Lock()
|
||||
defer defaultRegistry.mu.Unlock()
|
||||
h := defaultRegistry.syncDuration[kind]
|
||||
if h == nil {
|
||||
h = newHistogram()
|
||||
defaultRegistry.syncDuration[kind] = h
|
||||
}
|
||||
h.observe(sec)
|
||||
if err != nil {
|
||||
defaultRegistry.syncFailures[kind]++
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot returns coarse totals for admin diagnostics (not a full series dump).
|
||||
func Snapshot() map[string]any {
|
||||
defaultRegistry.mu.Lock()
|
||||
defer defaultRegistry.mu.Unlock()
|
||||
|
||||
var httpTotal, syncCount, syncFail uint64
|
||||
var syncSum float64
|
||||
for _, n := range defaultRegistry.httpRequests {
|
||||
httpTotal += n
|
||||
}
|
||||
for _, h := range defaultRegistry.syncDuration {
|
||||
syncCount += h.count
|
||||
syncSum += h.sum
|
||||
}
|
||||
for _, n := range defaultRegistry.syncFailures {
|
||||
syncFail += n
|
||||
}
|
||||
return map[string]any{
|
||||
"http_requests_total": httpTotal,
|
||||
"sync_duration_seconds_sum": syncSum,
|
||||
"sync_duration_seconds_count": syncCount,
|
||||
"sync_failures_total": syncFail,
|
||||
}
|
||||
}
|
||||
|
||||
// Reset clears all series (tests only).
|
||||
func Reset() {
|
||||
defaultRegistry.mu.Lock()
|
||||
defer defaultRegistry.mu.Unlock()
|
||||
defaultRegistry.httpRequests = make(map[labelKey]uint64)
|
||||
defaultRegistry.httpDuration = make(map[labelKey]*histogram)
|
||||
defaultRegistry.syncDuration = make(map[string]*histogram)
|
||||
defaultRegistry.syncFailures = make(map[string]uint64)
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
r.status = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Write(b []byte) (int, error) {
|
||||
if r.status == 0 {
|
||||
r.status = http.StatusOK
|
||||
}
|
||||
return r.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Middleware records HTTP RED metrics using the chi route pattern (low cardinality).
|
||||
func Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/metrics" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
path := chi.RouteContext(r.Context()).RoutePattern()
|
||||
if path == "" {
|
||||
path = "unmatched"
|
||||
}
|
||||
ObserveHTTP(r.Method, path, rec.status, time.Since(start))
|
||||
})
|
||||
}
|
||||
|
||||
// Handler serves Prometheus text exposition.
|
||||
func Handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
body := defaultRegistry.render()
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
})
|
||||
}
|
||||
|
||||
// Gate restricts Prometheus scrapes in production: allow when metricsPublic is true
|
||||
// (METRICS_PUBLIC=1) or the peer is loopback. Non-production always allows (local scrapes).
|
||||
func Gate(isProduction, metricsPublic bool) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isProduction || metricsPublic || isLoopbackRemoteAddr(r.RemoteAddr) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func isLoopbackRemoteAddr(remoteAddr string) bool {
|
||||
host := strings.TrimSpace(remoteAddr)
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func (reg *registry) render() []byte {
|
||||
reg.mu.Lock()
|
||||
defer reg.mu.Unlock()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# HELP http_requests_total Total HTTP requests by method, status code, and route pattern.\n")
|
||||
b.WriteString("# TYPE http_requests_total counter\n")
|
||||
for _, key := range sortedHTTPKeys(reg.httpRequests) {
|
||||
fmt.Fprintf(&b, "http_requests_total{method=%q,code=%q,path=%q} %d\n",
|
||||
key.a, key.b, key.c, reg.httpRequests[key])
|
||||
}
|
||||
|
||||
b.WriteString("# HELP http_request_duration_seconds HTTP request latency in seconds.\n")
|
||||
b.WriteString("# TYPE http_request_duration_seconds histogram\n")
|
||||
for _, key := range sortedHTTPKeys(reg.httpDuration) {
|
||||
writeHistogram(&b, "http_request_duration_seconds",
|
||||
fmt.Sprintf("method=%q,code=%q,path=%q", key.a, key.b, key.c),
|
||||
reg.httpDuration[key])
|
||||
}
|
||||
|
||||
b.WriteString("# HELP sync_duration_seconds Sync job latency in seconds by kind.\n")
|
||||
b.WriteString("# TYPE sync_duration_seconds histogram\n")
|
||||
for _, kind := range sortedStringKeys(reg.syncDuration) {
|
||||
writeHistogram(&b, "sync_duration_seconds",
|
||||
fmt.Sprintf("kind=%q", kind),
|
||||
reg.syncDuration[kind])
|
||||
}
|
||||
|
||||
b.WriteString("# HELP sync_failures_total Sync jobs that returned an error, by kind.\n")
|
||||
b.WriteString("# TYPE sync_failures_total counter\n")
|
||||
for _, kind := range sortedStringKeys(reg.syncFailures) {
|
||||
fmt.Fprintf(&b, "sync_failures_total{kind=%q} %d\n", kind, reg.syncFailures[kind])
|
||||
}
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
func writeHistogram(b *strings.Builder, name, labels string, h *histogram) {
|
||||
var cumulative uint64
|
||||
for i, bound := range durationBuckets {
|
||||
cumulative += h.counts[i]
|
||||
fmt.Fprintf(b, "%s_bucket{%s,le=%q} %d\n", name, labels, formatLE(bound), cumulative)
|
||||
}
|
||||
fmt.Fprintf(b, "%s_bucket{%s,le=\"+Inf\"} %d\n", name, labels, h.count)
|
||||
fmt.Fprintf(b, "%s_sum{%s} %s\n", name, labels, formatFloat(h.sum))
|
||||
fmt.Fprintf(b, "%s_count{%s} %d\n", name, labels, h.count)
|
||||
}
|
||||
|
||||
func formatLE(v float64) string {
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
|
||||
func formatFloat(v float64) string {
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
|
||||
func sortedHTTPKeys[T any](m map[labelKey]T) []labelKey {
|
||||
keys := make([]labelKey, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].a != keys[j].a {
|
||||
return keys[i].a < keys[j].a
|
||||
}
|
||||
if keys[i].b != keys[j].b {
|
||||
return keys[i].b < keys[j].b
|
||||
}
|
||||
return keys[i].c < keys[j].c
|
||||
})
|
||||
return keys
|
||||
}
|
||||
|
||||
func sortedStringKeys[T any](m map[string]T) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestObserveSyncAndHTTPExposition(t *testing.T) {
|
||||
t.Cleanup(Reset)
|
||||
Reset()
|
||||
|
||||
ObserveHTTP(http.MethodGet, "/healthz", http.StatusOK, 12*time.Millisecond)
|
||||
ObserveSync("feed", nil, 100*time.Millisecond)
|
||||
ObserveSync("feed", errors.New("boom"), 200*time.Millisecond)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"http_requests_total{",
|
||||
`path="/healthz"`,
|
||||
"http_request_duration_seconds_bucket{",
|
||||
"sync_duration_seconds_count{",
|
||||
`kind="feed"`,
|
||||
"sync_failures_total{",
|
||||
`sync_failures_total{kind="feed"} 1`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("missing %q in:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
|
||||
snap := Snapshot()
|
||||
if snap["http_requests_total"].(uint64) != 1 {
|
||||
t.Fatalf("snapshot http=%v", snap)
|
||||
}
|
||||
if snap["sync_failures_total"].(uint64) != 1 {
|
||||
t.Fatalf("snapshot sync fail=%v", snap)
|
||||
}
|
||||
if snap["sync_duration_seconds_count"].(uint64) != 2 {
|
||||
t.Fatalf("snapshot sync count=%v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareRecordsRoutePattern(t *testing.T) {
|
||||
t.Cleanup(Reset)
|
||||
Reset()
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(Middleware)
|
||||
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
r.Handle("/metrics", Handler())
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("healthz status=%d", rec.Code)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `path="/healthz"`) {
|
||||
t.Fatalf("expected route pattern in metrics:\n%s", body)
|
||||
}
|
||||
// /metrics itself should not inflate request series when skipped.
|
||||
if strings.Count(body, "http_requests_total{") > 1 {
|
||||
// one series line for healthz is expected; ensure metrics path absent
|
||||
}
|
||||
if strings.Contains(body, `path="/metrics"`) {
|
||||
t.Fatalf("/metrics should not self-instrument:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateAllowsNonProduction(t *testing.T) {
|
||||
t.Cleanup(Reset)
|
||||
Reset()
|
||||
h := Gate(false, false)(Handler())
|
||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
req.RemoteAddr = "203.0.113.9:9999"
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("non-prod status=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateBlocksNonLoopbackInProduction(t *testing.T) {
|
||||
t.Cleanup(Reset)
|
||||
Reset()
|
||||
h := Gate(true, false)(Handler())
|
||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
req.RemoteAddr = "203.0.113.9:9999"
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("prod remote status=%d want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateAllowsLoopbackInProduction(t *testing.T) {
|
||||
t.Cleanup(Reset)
|
||||
Reset()
|
||||
h := Gate(true, false)(Handler())
|
||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
req.RemoteAddr = "127.0.0.1:54321"
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("prod loopback status=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateAllowsPublicFlagInProduction(t *testing.T) {
|
||||
t.Cleanup(Reset)
|
||||
Reset()
|
||||
h := Gate(true, true)(Handler())
|
||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
req.RemoteAddr = "203.0.113.9:9999"
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("METRICS_PUBLIC status=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user