Cross Compile Go / build (push) Canceled after 0s
- embedded React admin UI at /admin/ui (go:embed), login with ADMIN_SECRET via signed HttpOnly session cookie, X-ADMIN-KEY still works for scripts - admin API: overview stats, indexing queue, addon management (priority, reindex, delete/restore/purge, bulk), whitelist CRUD, duplicate hashes, traffic, metrics explorer, services, events/audit log, system info - built-in metrics: request latency per route, visitors, top addons and searches, indexer/scraper counters; minute buckets in the DB with hourly rollups and retention; optional Prometheus /metrics (METRICS_TOKEN) - POST /back/telemetry for indexers/scrapers (metrics, events, heartbeat) and a stdlib-only Go client in telemetry/ - auth hardening: empty secrets no longer allow access, constant-time compare, CORS not applied to /admin and /back, TRUSTED_PROXIES setting - Dockerfile builds the UI and no longer bakes config.yaml into the image; CI builds the UI, runs tests and builds arm64 with CGO
287 lines
5.6 KiB
Go
287 lines
5.6 KiB
Go
package metrics
|
|
|
|
import (
|
|
"encoding/json"
|
|
"math"
|
|
"sort"
|
|
"sync"
|
|
"sync/atomic"
|
|
)
|
|
|
|
// disabled turns all recording into no-ops (METRICS_ENABLED=false).
|
|
var disabled atomic.Bool
|
|
|
|
// SetEnabled switches metric recording on or off.
|
|
func SetEnabled(on bool) { disabled.Store(!on) }
|
|
|
|
// Agg is the aggregate of all samples of one series inside one bucket.
|
|
type Agg struct {
|
|
Count int64
|
|
Sum float64
|
|
Min float64
|
|
Max float64
|
|
Last float64
|
|
LastTs int64
|
|
Hist []uint64 // len(bounds)+1, last element is +Inf
|
|
}
|
|
|
|
func (a *Agg) add(v float64, ts int64, bounds []float64) {
|
|
if a.Count == 0 {
|
|
a.Min, a.Max = v, v
|
|
} else {
|
|
a.Min = math.Min(a.Min, v)
|
|
a.Max = math.Max(a.Max, v)
|
|
}
|
|
a.Count++
|
|
a.Sum += v
|
|
if ts >= a.LastTs {
|
|
a.Last, a.LastTs = v, ts
|
|
}
|
|
if len(bounds) > 0 {
|
|
if a.Hist == nil {
|
|
a.Hist = make([]uint64, len(bounds)+1)
|
|
}
|
|
a.Hist[bucketIndex(bounds, v)]++
|
|
}
|
|
}
|
|
|
|
func (a *Agg) addSummary(count int64, sum, min, max float64, ts int64) {
|
|
if count <= 0 {
|
|
return
|
|
}
|
|
if a.Count == 0 {
|
|
a.Min, a.Max = min, max
|
|
} else {
|
|
a.Min = math.Min(a.Min, min)
|
|
a.Max = math.Max(a.Max, max)
|
|
}
|
|
a.Count += count
|
|
a.Sum += sum
|
|
if ts >= a.LastTs {
|
|
a.Last, a.LastTs = sum/float64(count), ts
|
|
}
|
|
}
|
|
|
|
// merge folds b into a.
|
|
func (a *Agg) merge(b *Agg) {
|
|
if b == nil || b.Count == 0 {
|
|
return
|
|
}
|
|
if a.Count == 0 {
|
|
a.Min, a.Max = b.Min, b.Max
|
|
} else {
|
|
a.Min = math.Min(a.Min, b.Min)
|
|
a.Max = math.Max(a.Max, b.Max)
|
|
}
|
|
a.Count += b.Count
|
|
a.Sum += b.Sum
|
|
if b.LastTs >= a.LastTs {
|
|
a.Last, a.LastTs = b.Last, b.LastTs
|
|
}
|
|
if len(b.Hist) > 0 {
|
|
if len(a.Hist) == 0 {
|
|
a.Hist = make([]uint64, len(b.Hist))
|
|
}
|
|
if len(a.Hist) == len(b.Hist) {
|
|
for i, v := range b.Hist {
|
|
a.Hist[i] += v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *Agg) clone() *Agg {
|
|
c := *a
|
|
if a.Hist != nil {
|
|
c.Hist = append([]uint64(nil), a.Hist...)
|
|
}
|
|
return &c
|
|
}
|
|
|
|
func (a *Agg) histJSON() string {
|
|
if len(a.Hist) == 0 {
|
|
return ""
|
|
}
|
|
b, _ := json.Marshal(a.Hist)
|
|
return string(b)
|
|
}
|
|
|
|
func bucketIndex(bounds []float64, v float64) int {
|
|
return sort.SearchFloat64s(bounds, v) // first bound >= v; len(bounds) = +Inf
|
|
}
|
|
|
|
// quantile estimates the q-quantile from histogram buckets with linear
|
|
// interpolation inside the bucket (like Prometheus), clamped to [min, max].
|
|
func quantile(a *Agg, bounds []float64, q float64) (float64, bool) {
|
|
if a == nil || a.Count == 0 || len(a.Hist) != len(bounds)+1 {
|
|
return 0, false
|
|
}
|
|
var total uint64
|
|
for _, c := range a.Hist {
|
|
total += c
|
|
}
|
|
if total == 0 {
|
|
return 0, false
|
|
}
|
|
rank := q * float64(total)
|
|
var cum uint64
|
|
for i, c := range a.Hist {
|
|
if c == 0 {
|
|
continue
|
|
}
|
|
if float64(cum+c) >= rank {
|
|
lower := a.Min
|
|
if i > 0 {
|
|
lower = math.Max(bounds[i-1], a.Min)
|
|
}
|
|
upper := a.Max
|
|
if i < len(bounds) {
|
|
upper = math.Min(bounds[i], a.Max)
|
|
}
|
|
if upper < lower {
|
|
upper = lower
|
|
}
|
|
frac := (rank - float64(cum)) / float64(c)
|
|
return lower + (upper-lower)*frac, true
|
|
}
|
|
cum += c
|
|
}
|
|
return a.Max, true
|
|
}
|
|
|
|
type pointKey struct {
|
|
s *Series
|
|
bucket int64
|
|
}
|
|
|
|
// cumulative is the since-start state of a series, used for /metrics.
|
|
type cumulative struct {
|
|
count int64
|
|
sum float64
|
|
last float64
|
|
hist []uint64
|
|
}
|
|
|
|
// aggregator holds the not yet persisted minute buckets and cumulative state.
|
|
type aggregator struct {
|
|
mu sync.Mutex
|
|
open map[pointKey]*Agg
|
|
cum map[*Series]*cumulative
|
|
}
|
|
|
|
var defaultAgg = &aggregator{
|
|
open: make(map[pointKey]*Agg),
|
|
cum: make(map[*Series]*cumulative),
|
|
}
|
|
|
|
func minuteOf(ts int64) int64 { return ts - ts%60 }
|
|
|
|
func (g *aggregator) entry(s *Series, ts int64) (*Agg, *cumulative) {
|
|
k := pointKey{s, minuteOf(ts)}
|
|
a := g.open[k]
|
|
if a == nil {
|
|
a = &Agg{}
|
|
g.open[k] = a
|
|
}
|
|
c := g.cum[s]
|
|
if c == nil {
|
|
c = &cumulative{}
|
|
g.cum[s] = c
|
|
}
|
|
return a, c
|
|
}
|
|
|
|
func (g *aggregator) observe(s *Series, ts int64, v float64) {
|
|
if disabled.Load() {
|
|
return
|
|
}
|
|
g.mu.Lock()
|
|
a, c := g.entry(s, ts)
|
|
a.add(v, ts, s.Bounds)
|
|
c.count++
|
|
c.sum += v
|
|
c.last = v
|
|
if len(s.Bounds) > 0 {
|
|
if c.hist == nil {
|
|
c.hist = make([]uint64, len(s.Bounds)+1)
|
|
}
|
|
c.hist[bucketIndex(s.Bounds, v)]++
|
|
}
|
|
g.mu.Unlock()
|
|
reg.touch(s, ts)
|
|
}
|
|
|
|
func (g *aggregator) observeSummary(s *Series, ts int64, count int64, sum, min, max float64) {
|
|
if disabled.Load() {
|
|
return
|
|
}
|
|
g.mu.Lock()
|
|
a, c := g.entry(s, ts)
|
|
a.addSummary(count, sum, min, max, ts)
|
|
c.count += count
|
|
c.sum += sum
|
|
c.last = sum / float64(count)
|
|
g.mu.Unlock()
|
|
reg.touch(s, ts)
|
|
}
|
|
|
|
// takeClosed removes and returns all buckets that started before `before`.
|
|
func (g *aggregator) takeClosed(before int64) map[pointKey]*Agg {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
out := make(map[pointKey]*Agg)
|
|
for k, a := range g.open {
|
|
if k.bucket < before {
|
|
out[k] = a
|
|
delete(g.open, k)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// putBack re-queues buckets after a failed flush.
|
|
func (g *aggregator) putBack(points map[pointKey]*Agg) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
for k, a := range points {
|
|
if cur := g.open[k]; cur != nil {
|
|
cur.merge(a)
|
|
} else {
|
|
g.open[k] = a
|
|
}
|
|
}
|
|
}
|
|
|
|
// snapshotOpen copies the open buckets of the given series.
|
|
func (g *aggregator) snapshotOpen(want map[*Series]bool) map[pointKey]*Agg {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
out := make(map[pointKey]*Agg)
|
|
for k, a := range g.open {
|
|
if want[k.s] {
|
|
out[k] = a.clone()
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (g *aggregator) openCount() int {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return len(g.open)
|
|
}
|
|
|
|
func (g *aggregator) snapshotCum() map[*Series]cumulative {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
out := make(map[*Series]cumulative, len(g.cum))
|
|
for s, c := range g.cum {
|
|
cc := *c
|
|
if c.hist != nil {
|
|
cc.hist = append([]uint64(nil), c.hist...)
|
|
}
|
|
out[s] = cc
|
|
}
|
|
return out
|
|
}
|