Files

220 lines
5.6 KiB
Go
Raw Permalink Normal View History

package observability
import (
"fmt"
"net/http"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
// Lightweight in-process Prometheus text metrics (no promhttp / compress deps).
type counterVec struct {
mu sync.Mutex
name string
help string
labels []string
values map[string]*atomic.Int64
}
func newCounterVec(name, help string, labels []string) *counterVec {
return &counterVec{
name: name,
help: help,
labels: labels,
values: map[string]*atomic.Int64{},
}
}
func (c *counterVec) WithLabelValues(vals ...string) *atomic.Int64 {
key := strings.Join(vals, "\x00")
c.mu.Lock()
defer c.mu.Unlock()
if v, ok := c.values[key]; ok {
return v
}
v := &atomic.Int64{}
c.values[key] = v
return v
}
func (c *counterVec) Inc(vals ...string) {
c.WithLabelValues(vals...).Add(1)
}
func (c *counterVec) write(b *strings.Builder) {
c.mu.Lock()
defer c.mu.Unlock()
fmt.Fprintf(b, "# HELP %s %s\n# TYPE %s counter\n", c.name, c.help, c.name)
keys := make([]string, 0, len(c.values))
for k := range c.values {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
parts := strings.Split(k, "\x00")
var labelPairs []string
for i, lab := range c.labels {
val := ""
if i < len(parts) {
val = parts[i]
}
labelPairs = append(labelPairs, fmt.Sprintf(`%s="%s"`, lab, escapeLabel(val)))
}
fmt.Fprintf(b, "%s{%s} %d\n", c.name, strings.Join(labelPairs, ","), c.values[k].Load())
}
}
type counter struct {
name string
help string
value atomic.Int64
}
func newCounter(name, help string) *counter {
return &counter{name: name, help: help}
}
func (c *counter) Inc() { c.value.Add(1) }
func (c *counter) write(b *strings.Builder) {
fmt.Fprintf(b, "# HELP %s %s\n# TYPE %s counter\n%s %d\n", c.name, c.help, c.name, c.name, c.value.Load())
}
type histVec struct {
mu sync.Mutex
name string
help string
buckets []float64
obs map[string]*histObs
}
type histObs struct {
counts []atomic.Uint64
sum atomic.Uint64 // milliseconds * 1000 for precision via float bits — use float64 via mutex instead
sumMu sync.Mutex
sumF float64
count atomic.Uint64
}
func newHistVec(name, help string, buckets []float64) *histVec {
return &histVec{
name: name,
help: help,
buckets: buckets,
obs: map[string]*histObs{},
}
}
func (h *histVec) Observe(method, path string, seconds float64) {
key := method + "\x00" + path
h.mu.Lock()
o, ok := h.obs[key]
if !ok {
o = &histObs{counts: make([]atomic.Uint64, len(h.buckets))}
h.obs[key] = o
}
h.mu.Unlock()
for i, b := range h.buckets {
if seconds <= b {
o.counts[i].Add(1)
}
}
o.count.Add(1)
o.sumMu.Lock()
o.sumF += seconds
o.sumMu.Unlock()
}
func (h *histVec) write(b *strings.Builder) {
h.mu.Lock()
defer h.mu.Unlock()
fmt.Fprintf(b, "# HELP %s %s\n# TYPE %s histogram\n", h.name, h.help, h.name)
keys := make([]string, 0, len(h.obs))
for k := range h.obs {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
parts := strings.Split(k, "\x00")
method, path := parts[0], ""
if len(parts) > 1 {
path = parts[1]
}
base := fmt.Sprintf(`method="%s",path="%s"`, escapeLabel(method), escapeLabel(path))
o := h.obs[k]
for i, bound := range h.buckets {
fmt.Fprintf(b, `%s_bucket{%s,le="%g"} %d`+"\n", h.name, base, bound, o.counts[i].Load())
}
fmt.Fprintf(b, `%s_bucket{%s,le="+Inf"} %d`+"\n", h.name, base, o.count.Load())
o.sumMu.Lock()
sum := o.sumF
o.sumMu.Unlock()
fmt.Fprintf(b, "%s_sum{%s} %g\n", h.name, base, sum)
fmt.Fprintf(b, "%s_count{%s} %d\n", h.name, base, o.count.Load())
}
}
func escapeLabel(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
s = strings.ReplaceAll(s, "\n", `\n`)
return s
}
var (
httpRequests = newCounterVec("pargar_http_requests_total", "Total HTTP requests", []string{"method", "path", "status"})
httpDuration = newHistVec("pargar_http_request_duration_seconds", "HTTP request duration", []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10})
quizSubmits = newCounterVec("pargar_quiz_submits_total", "Quiz submit outcomes", []string{"result"})
lockouts = newCounter("pargar_account_lockouts_total", "Accounts locked after heart depletion")
heartbeats = newCounter("pargar_watch_heartbeats_total", "Accepted watch heartbeats")
videoUploads = newCounter("pargar_video_uploads_total", "Admin video uploads")
logins = newCounterVec("pargar_auth_logins_total", "Login attempts", []string{"result"})
)
// Compatibility wrappers matching previous call sites.
type labeledInc struct{ vec *counterVec }
func (l labeledInc) WithLabelValues(vals ...string) interface{ Inc() } {
return labeledCounter{c: l.vec.WithLabelValues(vals...)}
}
type labeledCounter struct{ c *atomic.Int64 }
func (l labeledCounter) Inc() { l.c.Add(1) }
var (
HTTPRequests = labeledInc{httpRequests}
QuizSubmits = labeledInc{quizSubmits}
Logins = labeledInc{logins}
Lockouts = lockouts
Heartbeats = heartbeats
VideoUploads = videoUploads
)
// ObserveHTTP records request metrics.
func ObserveHTTP(method, path string, status int, d time.Duration) {
httpRequests.Inc(method, path, fmt.Sprintf("%d", status))
httpDuration.Observe(method, path, d.Seconds())
}
// MetricsHandler serves Prometheus text exposition.
func MetricsHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var b strings.Builder
httpRequests.write(&b)
httpDuration.write(&b)
quizSubmits.write(&b)
lockouts.write(&b)
heartbeats.write(&b)
videoUploads.write(&b)
logins.write(&b)
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
_, _ = w.Write([]byte(b.String()))
})
}