admin dashboard, metrics and telemetry
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
This commit is contained in:
ilbinek committed 2026-10-03 18:10:57 +02:00
1 parent 0ef27b0072
commit 56d9e10621
79 files changed
+13146 -118

No files matched your search

+286
View File
@@ -0,0 +1,286 @@
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
}
+38
View File
@@ -0,0 +1,38 @@
package metrics
// Metrics emitted by the API itself (service "main").
// HTTPBoundsMs are the latency histogram buckets in milliseconds.
var HTTPBoundsMs = []float64{1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000}
var (
HTTPDuration = NewHistogram("http_request_duration_ms", "HTTP request latency in milliseconds", HTTPBoundsMs,
"method", "route", "status", "group", "client")
HTTPResponseBytes = NewCounter("http_response_bytes_total", "HTTP response body bytes", "group")
IndexerClaims = NewCounter("indexer_claims_total", "Indexing jobs requested by indexers, by outcome", "queue")
IndexerTimeoutsReset = NewCounter("indexer_timeouts_reset_total", "Indexing claims released after the timeout")
IndexerSubmissions = NewCounter("indexer_submissions_total", "Indexing results submitted", "result")
IndexerJobDuration = NewHistogram("indexer_job_duration_seconds", "Time from claim to submitted result",
[]float64{1, 5, 15, 30, 60, 120, 300, 600, 1200, 1800, 3600})
IndexerFilesPerSubmission = NewHistogram("indexer_files_per_submission", "Files per indexing result",
[]float64{1, 10, 50, 100, 500, 1000, 5000, 10000, 50000})
IndexerBytesIndexed = NewCounter("indexer_bytes_indexed_total", "Addon bytes indexed")
ScraperAddons = NewCounter("scraper_addons_total", "Addon reports from scrapers", "action")
ScraperDeletes = NewCounter("scraper_addons_deleted_total", "Addon deletions requested by scrapers", "result")
DependencyReports = NewCounter("dependency_reports_total", "Dependency reports received")
DependencyEdges = NewCounter("dependency_edges_reported_total", "Dependency edges stored")
DuplicatesFound = NewHistogram("duplicates_found_addons", "Addons sharing files with a looked-up addon",
[]float64{0, 1, 2, 5, 10, 25, 50, 100})
DiscordWebhooks = NewCounter("discord_webhook_total", "Discord webhook sends", "kind", "result")
AdminActions = NewCounter("admin_actions_total", "Admin dashboard actions", "action")
AddonsTotal = NewGauge("addons_total", "Addons in the database (not deleted)")
QueueDepth = NewGauge("queue_depth", "Addons waiting to be indexed", "kind")
BeingIndexed = NewGauge("being_indexed", "Addons currently claimed by an indexer")
VisitorsUnique = NewGauge("visitors_unique", "Unique website visitors so far today (approximate)", "kind")
TelemetrySamples = NewCounter("telemetry_samples_total", "Telemetry samples received", "result")
PointsDropped = NewCounter("metrics_points_dropped_total", "Metric points dropped after failed writes")
)
+216
View File
@@ -0,0 +1,216 @@
package metrics
import (
"math"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/initializers"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func setupDB(t *testing.T) {
t.Helper()
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "m.db")), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&models.Addon{}, &models.MetricSeries{}, &models.MetricPoint{},
&models.ServiceInstance{}, &models.ServiceEvent{}, &models.DailyTopItem{}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
sqlDB.Close()
}
})
initializers.DB = db
initializers.MetricsMinuteRetentionDays = 7
initializers.MetricsHourRetentionDays = 180
// fresh in-memory state per test
reg = &registry{series: map[seriesKey]*Series{}, perName: map[string]int{}, lastSeen: map[*Series]int64{}}
defaultAgg = &aggregator{open: map[pointKey]*Agg{}, cum: map[*Series]*cumulative{}}
seriesID = map[*Series]uint{}
rolledUntil.Store(0)
for _, f := range families {
f.cache = map[string]*Series{}
}
}
func TestQuantileInterpolation(t *testing.T) {
bounds := []float64{10, 20, 30}
a := &Agg{}
for i := 1; i <= 100; i++ {
a.add(float64(i%30)+0.5, 0, bounds)
}
p50, ok := quantile(a, bounds, 0.5)
if !ok || p50 < 10 || p50 > 20 {
t.Fatalf("p50 = %v, want within (10, 20]", p50)
}
p99, _ := quantile(a, bounds, 0.99)
if p99 > a.Max || p99 < 20 {
t.Fatalf("p99 = %v, want in [20, max=%v]", p99, a.Max)
}
if _, ok := quantile(&Agg{}, bounds, 0.5); ok {
t.Fatal("empty histogram must not produce a quantile")
}
}
func TestAggMerge(t *testing.T) {
a, b := &Agg{}, &Agg{}
a.add(5, 100, nil)
a.add(1, 101, nil)
b.add(9, 99, nil)
a.merge(b)
if a.Count != 3 || a.Sum != 15 || a.Min != 1 || a.Max != 9 || a.Last != 1 {
t.Fatalf("unexpected merge result %+v", a)
}
}
func TestConcurrentObserve(t *testing.T) {
setupDB(t)
f := NewCounter("test_concurrent_total", "test", "worker")
var wg sync.WaitGroup
for w := range 8 {
wg.Add(1)
go func(w int) {
defer wg.Done()
for range 1000 {
f.Inc(string(rune('a' + w%2)))
}
}(w)
}
wg.Wait()
var total float64
for s, c := range defaultAgg.snapshotCum() {
if s.Name == "test_concurrent_total" {
total += c.sum
}
}
if total != 8000 {
t.Fatalf("total = %v, want 8000", total)
}
}
func TestFlushQueryRollup(t *testing.T) {
setupDB(t)
h := NewHistogram("test_latency_ms", "test", []float64{1, 10, 100}, "route")
s := h.series([]string{"/a"})
// two hours ago, spread over three minutes, plus one point in the current minute
base := hourOf(time.Now().Unix()) - 2*3600
for i := range 30 {
defaultAgg.observe(s, base+int64(i%3)*60, float64(i))
}
now := time.Now().Unix()
defaultAgg.observe(s, now, 50)
if err := flush(minuteOf(now)); err != nil {
t.Fatal(err)
}
var rows int64
initializers.DB.Model(&models.MetricPoint{}).Where("resolution = ?", resMinute).Count(&rows)
if rows != 3 {
t.Fatalf("minute rows = %d, want 3 (current minute stays in memory)", rows)
}
// flushing the same bucket again must merge, not overwrite
defaultAgg.observe(s, base, 1000)
if err := flush(minuteOf(now)); err != nil {
t.Fatal(err)
}
var p models.MetricPoint
initializers.DB.Where("resolution = ? AND bucket = ?", resMinute, base).First(&p)
if p.Count != 11 || p.Max != 1000 {
t.Fatalf("merged point = %+v, want count 11 and max 1000", p)
}
// query sees DB rows plus the unflushed current minute
sel := Selector{Service: MainService, Name: "test_latency_ms"}
res, err := Query(sel, "count", now-3*3600, now, 60)
if err != nil {
t.Fatal(err)
}
var count float64
for _, pt := range res.Series[0].Points {
if pt.V != nil {
count += *pt.V
}
}
if count != 32 {
t.Fatalf("queried count = %v, want 32", count)
}
if err := rollup(now); err != nil {
t.Fatal(err)
}
var hourly models.MetricPoint
if err := initializers.DB.Where("resolution = ? AND bucket = ?", resHour, base).First(&hourly).Error; err != nil {
t.Fatalf("missing hourly rollup: %v", err)
}
if hourly.Count != 31 || hourly.Max != 1000 {
t.Fatalf("hourly = %+v, want count 31 max 1000", hourly)
}
// rolling up again is idempotent
rolledUntil.Store(0)
if err := rollup(now); err != nil {
t.Fatal(err)
}
initializers.DB.Where("resolution = ? AND bucket = ?", resHour, base).First(&hourly)
if hourly.Count != 31 {
t.Fatalf("re-rollup changed count to %d", hourly.Count)
}
totals, err := Totals(sel, now-3*3600, now)
if err != nil || len(totals) != 1 || totals[0].P95 == nil || math.IsNaN(*totals[0].P95) {
t.Fatalf("totals = %+v, err %v", totals, err)
}
}
func TestIngestValidation(t *testing.T) {
setupDB(t)
now := time.Now()
res := Ingest("indexer", "i-1", []models.TelemetryMetric{
{Name: "ok_total", Type: "counter", Value: 2},
{Name: "Bad-Name", Type: "gauge", Value: 1},
{Name: "neg_total", Type: "counter", Value: -1},
{Name: "lat", Type: "summary", Count: 2, Sum: 3, Min: 1, Max: 2},
{Name: "ok_total", Type: "gauge", Value: 1},
{Name: "weird", Type: "nope"},
}, now)
if res.Accepted != 2 || res.Dropped != 4 {
t.Fatalf("accepted %d dropped %d (%v)", res.Accepted, res.Dropped, res.Reasons)
}
if res.Reasons["type_mismatch"] != 1 || res.Reasons["invalid_name"] != 1 {
t.Fatalf("unexpected reasons %v", res.Reasons)
}
}
func TestPrometheusOutput(t *testing.T) {
setupDB(t)
c := NewCounter("test_prom_total", "a \"quoted\" help", "kind")
c.Add(3, `va"l`)
h := NewHistogram("test_prom_ms", "hist", []float64{1, 10})
h.Observe(5)
var b strings.Builder
WritePrometheus(&b)
out := b.String()
for _, want := range []string{
`test_prom_total{kind="va\"l"} 3`,
`test_prom_ms_bucket{le="1"} 0`,
`test_prom_ms_bucket{le="10"} 1`,
`test_prom_ms_bucket{le="+Inf"} 1`,
`# TYPE test_prom_ms histogram`,
} {
if !strings.Contains(out, want) {
t.Errorf("missing %q in output", want)
}
}
}
+156
View File
@@ -0,0 +1,156 @@
package metrics
import (
"context"
"log"
"math"
"sync"
"time"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/initializers"
)
var startOnce sync.Once
// Start launches the background jobs: minute flush and samplers, top-N
// persistence, hourly rollup and retention, and service heartbeat checks.
func Start(ctx context.Context) {
startOnce.Do(func() {
Top.seed()
go every(ctx, "flush", time.Minute, 5*time.Second, func(now time.Time) {
sample()
if err := flush(minuteOf(now.Unix())); err != nil {
log.Printf("metrics: flush failed: %v", err)
}
})
go every(ctx, "top", 5*time.Minute, 0, func(time.Time) {
if err := Top.flush(); err != nil {
log.Printf("metrics: top items flush failed: %v", err)
}
})
go every(ctx, "rollup", time.Hour, 2*time.Minute, func(now time.Time) {
RunMaintenance(now)
})
go every(ctx, "services", 30*time.Second, 0, func(now time.Time) {
selfHeartbeat(now)
checkServices(now)
})
// initialize the rollup watermark and service state right away
go func() {
selfHeartbeat(time.Now())
checkServices(time.Now())
if err := rollup(time.Now().Unix()); err != nil {
log.Printf("metrics: rollup failed: %v", err)
}
}()
})
}
// RunMaintenance runs the rollup and retention jobs.
func RunMaintenance(now time.Time) {
if err := rollup(now.Unix()); err != nil {
log.Printf("metrics: rollup failed: %v", err)
}
if err := retention(now); err != nil {
log.Printf("metrics: retention failed: %v", err)
}
}
// every runs fn on a fixed period aligned to wall clock (+offset).
func every(ctx context.Context, name string, period, offset time.Duration, fn func(time.Time)) {
for {
now := time.Now()
next := now.Truncate(period).Add(offset)
if !next.After(now) {
next = next.Add(period)
}
select {
case <-ctx.Done():
return
case t := <-time.After(time.Until(next)):
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("metrics: %s job panicked: %v", name, r)
}
}()
fn(t)
}()
}
}
}
// FlushAll writes everything still in memory; used on shutdown.
func FlushAll() {
sample()
if err := flush(math.MaxInt64); err != nil {
log.Printf("metrics: final flush failed: %v", err)
}
if err := Top.flush(); err != nil {
log.Printf("metrics: final top items flush failed: %v", err)
}
}
// QueueCounts is a cheap snapshot of the indexing queue.
type QueueCounts struct {
Total int64 `json:"total"`
Queued int64 `json:"queued"`
Priority int64 `json:"priority"`
Indexing int64 `json:"indexing"`
}
// LoadQueueCounts counts addons by queue state with one query.
func LoadQueueCounts() (QueueCounts, error) {
var qc QueueCounts
err := initializers.DB.Raw(`SELECT COUNT(*) AS total,
COALESCE(SUM(CASE WHEN to_be_indexed = ? AND is_being_indexed = ? THEN 1 ELSE 0 END), 0) AS queued,
COALESCE(SUM(CASE WHEN to_be_indexed = ? AND is_being_indexed = ? AND priority_indexing = ? THEN 1 ELSE 0 END), 0) AS priority,
COALESCE(SUM(CASE WHEN is_being_indexed = ? THEN 1 ELSE 0 END), 0) AS indexing
FROM addons WHERE deleted_at IS NULL`,
true, false, true, false, true, true).Scan(&qc).Error
return qc, err
}
// sample records gauges that are read from the database or memory.
func sample() {
if qc, err := LoadQueueCounts(); err == nil {
AddonsTotal.Set(float64(qc.Total))
QueueDepth.Set(float64(qc.Queued), "total")
QueueDepth.Set(float64(qc.Priority), "priority")
BeingIndexed.Set(float64(qc.Indexing))
}
humans, bots := liveVisitors()
VisitorsUnique.Set(float64(humans), "human")
VisitorsUnique.Set(float64(bots), "bot")
}
// PipelineStatus describes the health of the metrics pipeline.
type PipelineStatus struct {
Enabled bool `json:"enabled"`
LastFlush int64 `json:"lastFlush"`
LastFlushMs int64 `json:"lastFlushMs"`
LastFlushErr string `json:"lastFlushError"`
LastRollup int64 `json:"lastRollup"`
LastRetention int64 `json:"lastRetention"`
RolledUntil int64 `json:"rolledUntil"`
OpenPoints int `json:"openPoints"`
LiveSeries int `json:"liveSeries"`
DroppedPoints int64 `json:"droppedPoints"`
}
func Status() PipelineStatus {
status.mu.Lock()
defer status.mu.Unlock()
return PipelineStatus{
Enabled: initializers.MetricsEnabled,
LastFlush: status.LastFlush,
LastFlushMs: status.LastFlushMs,
LastFlushErr: status.LastFlushErr,
LastRollup: status.LastRollup,
LastRetention: status.LastRetention,
RolledUntil: rolledUntil.Load(),
OpenPoints: defaultAgg.openCount(),
LiveSeries: len(reg.all()),
DroppedPoints: status.Dropped,
}
}
+181
View File
@@ -0,0 +1,181 @@
package metrics
import (
"bufio"
"io"
"runtime"
"sort"
"strconv"
"strings"
"time"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/initializers"
)
// WritePrometheus writes all cumulative series in the Prometheus text format
// (version 0.0.4). External metrics are exposed as <service>_<name> with an
// "instance" label; counters are totals since this process started.
func WritePrometheus(w io.Writer) {
bw := bufio.NewWriter(w)
defer bw.Flush()
cum := defaultAgg.snapshotCum()
type entry struct {
s *Series
c cumulative
}
byName := map[string][]entry{}
meta := map[string]*Series{}
for s, c := range cum {
name := s.Name
if s.Service != MainService {
name = s.Service + "_" + s.Name
}
byName[name] = append(byName[name], entry{s, c})
if meta[name] == nil {
meta[name] = s
}
}
names := make([]string, 0, len(byName))
for n := range byName {
names = append(names, n)
}
sort.Strings(names)
for _, name := range names {
s := meta[name]
writeHeader(bw, name, s.Help, promType(s.Kind))
entries := byName[name]
sort.Slice(entries, func(i, j int) bool {
if entries[i].s.Instance != entries[j].s.Instance {
return entries[i].s.Instance < entries[j].s.Instance
}
return entries[i].s.LabelsJSON < entries[j].s.LabelsJSON
})
for _, e := range entries {
labels := e.s.Labels
if e.s.Service != MainService {
labels = withLabel(labels, "instance", e.s.Instance)
}
switch e.s.Kind {
case Counter:
writeSample(bw, name, labels, e.c.sum)
case Gauge:
writeSample(bw, name, labels, e.c.last)
case Summary:
writeSample(bw, name+"_sum", labels, e.c.sum)
writeSample(bw, name+"_count", labels, float64(e.c.count))
case Histogram:
var acc uint64
for i, b := range e.s.Bounds {
if i < len(e.c.hist) {
acc += e.c.hist[i]
}
writeSample(bw, name+"_bucket", withLabel(labels, "le", formatFloat(b)), float64(acc))
}
writeSample(bw, name+"_bucket", withLabel(labels, "le", "+Inf"), float64(e.c.count))
writeSample(bw, name+"_sum", labels, e.c.sum)
writeSample(bw, name+"_count", labels, float64(e.c.count))
}
}
}
up := ServiceUp()
if len(up) > 0 {
writeHeader(bw, "reforger_service_up", "Whether a reporting service instance is sending heartbeats", "gauge")
keys := make([][2]string, 0, len(up))
for k := range up {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return keys[i][0]+keys[i][1] < keys[j][0]+keys[j][1] })
for _, k := range keys {
v := 0.0
if up[k] {
v = 1
}
writeSample(bw, "reforger_service_up", map[string]string{"service": k[0], "instance": k[1]}, v)
}
}
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
writeHeader(bw, "go_goroutines", "Number of goroutines", "gauge")
writeSample(bw, "go_goroutines", nil, float64(runtime.NumGoroutine()))
writeHeader(bw, "go_memstats_heap_alloc_bytes", "Heap bytes allocated and in use", "gauge")
writeSample(bw, "go_memstats_heap_alloc_bytes", nil, float64(ms.HeapAlloc))
writeHeader(bw, "go_memstats_sys_bytes", "Bytes obtained from the OS", "gauge")
writeSample(bw, "go_memstats_sys_bytes", nil, float64(ms.Sys))
writeHeader(bw, "go_gc_cycles_total", "Completed GC cycles", "counter")
writeSample(bw, "go_gc_cycles_total", nil, float64(ms.NumGC))
writeHeader(bw, "process_start_time_seconds", "Process start time", "gauge")
writeSample(bw, "process_start_time_seconds", nil, float64(startedAt.Unix()))
writeHeader(bw, "process_uptime_seconds", "Process uptime", "gauge")
writeSample(bw, "process_uptime_seconds", nil, time.Since(startedAt).Seconds())
if sqlDB, err := initializers.DB.DB(); err == nil {
st := sqlDB.Stats()
writeHeader(bw, "db_open_connections", "Open database connections", "gauge")
writeSample(bw, "db_open_connections", nil, float64(st.OpenConnections))
writeHeader(bw, "db_in_use_connections", "Database connections in use", "gauge")
writeSample(bw, "db_in_use_connections", nil, float64(st.InUse))
writeHeader(bw, "db_wait_count_total", "Waits for a database connection", "counter")
writeSample(bw, "db_wait_count_total", nil, float64(st.WaitCount))
writeHeader(bw, "db_wait_seconds_total", "Time spent waiting for a database connection", "counter")
writeSample(bw, "db_wait_seconds_total", nil, st.WaitDuration.Seconds())
}
}
func promType(k Kind) string {
switch k {
case Counter:
return "counter"
case Histogram:
return "histogram"
case Summary:
return "summary"
}
return "gauge"
}
func withLabel(labels map[string]string, k, v string) map[string]string {
out := make(map[string]string, len(labels)+1)
for lk, lv := range labels {
out[lk] = lv
}
out[k] = v
return out
}
func formatFloat(v float64) string { return strconv.FormatFloat(v, 'g', -1, 64) }
var helpEscaper = strings.NewReplacer(`\`, `\\`, "\n", `\n`)
var valueEscaper = strings.NewReplacer(`\`, `\\`, "\n", `\n`, `"`, `\"`)
func writeHeader(w *bufio.Writer, name, help, typ string) {
if help != "" {
w.WriteString("# HELP " + name + " " + helpEscaper.Replace(help) + "\n")
}
w.WriteString("# TYPE " + name + " " + typ + "\n")
}
func writeSample(w *bufio.Writer, name string, labels map[string]string, v float64) {
w.WriteString(name)
if len(labels) > 0 {
keys := make([]string, 0, len(labels))
for k := range labels {
keys = append(keys, k)
}
sort.Strings(keys)
w.WriteByte('{')
for i, k := range keys {
if i > 0 {
w.WriteByte(',')
}
w.WriteString(k + `="` + valueEscaper.Replace(labels[k]) + `"`)
}
w.WriteByte('}')
}
w.WriteByte(' ')
w.WriteString(formatFloat(v))
w.WriteByte('\n')
}
+526
View File
@@ -0,0 +1,526 @@
package metrics
import (
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/initializers"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/models"
)
const (
maxQuerySeries = 50
maxQueryPoints = 1500
)
// Selector picks series by service, name and exact label matches, and groups
// them by the GroupBy labels ("service" and "instance" work as labels too).
type Selector struct {
Service string
Name string
Match map[string]string
GroupBy []string
}
// Aggregations supported by Query and Totals.
var Aggregations = []string{"sum", "count", "rate", "avg", "min", "max", "last", "p50", "p90", "p95", "p99"}
func validAgg(agg string) bool {
for _, a := range Aggregations {
if a == agg {
return true
}
}
return false
}
// DefaultAgg picks a sensible aggregation for a metric kind.
func DefaultAgg(kind Kind) string {
switch kind {
case Counter:
return "sum"
case Gauge:
return "last"
case Histogram:
return "p95"
default:
return "avg"
}
}
// Point is [unix seconds, value or null] in JSON.
type Point struct {
T int64
V *float64
}
func (p Point) MarshalJSON() ([]byte, error) {
if p.V == nil {
return []byte("[" + strconv.FormatInt(p.T, 10) + ",null]"), nil
}
return []byte("[" + strconv.FormatInt(p.T, 10) + "," + strconv.FormatFloat(*p.V, 'g', 10, 64) + "]"), nil
}
type SeriesResult struct {
Labels map[string]string `json:"labels"`
Points []Point `json:"points"`
}
type QueryResult struct {
Name string `json:"name"`
Kind Kind `json:"kind"`
Agg string `json:"agg"`
Resolution int64 `json:"resolution"`
Step int64 `json:"step"`
From int64 `json:"from"`
To int64 `json:"to"`
Series []SeriesResult `json:"series"`
Truncated bool `json:"truncated"`
}
// matched is a series selected for a query, possibly only known from the DB.
type matched struct {
id uint
live *Series // nil when this process never saw the series
labels map[string]string
svc string
inst string
kind Kind
bounds []float64
}
func (m *matched) label(name string) string {
switch name {
case "service":
if v, ok := m.labels["service"]; ok {
return v
}
return m.svc
case "instance":
if v, ok := m.labels["instance"]; ok {
return v
}
return m.inst
}
return m.labels[name]
}
func (sel Selector) matches(m *matched) bool {
if sel.Service != "" && m.svc != sel.Service {
return false
}
for k, v := range sel.Match {
if m.label(k) != v {
return false
}
}
return true
}
func findSeries(sel Selector) ([]*matched, error) {
byKey := map[seriesKey]*matched{}
q := initializers.DB.Model(&models.MetricSeries{}).Where("name = ?", sel.Name)
if sel.Service != "" {
q = q.Where("service = ?", sel.Service)
}
var rows []models.MetricSeries
if err := q.Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
m := &matched{id: r.ID, svc: r.Service, inst: r.Instance, kind: Kind(r.Kind), labels: map[string]string{}}
_ = json.Unmarshal([]byte(r.Labels), &m.labels)
if r.Bounds != "" {
_ = json.Unmarshal([]byte(r.Bounds), &m.bounds)
}
byKey[seriesKey{r.Service, r.Instance, r.Name, r.Labels}] = m
}
for _, s := range reg.all() {
if s.Name != sel.Name || (sel.Service != "" && s.Service != sel.Service) {
continue
}
k := s.key()
if m := byKey[k]; m != nil {
m.live = s
continue
}
byKey[k] = &matched{live: s, svc: s.Service, inst: s.Instance, kind: s.Kind, bounds: s.Bounds, labels: s.Labels}
}
out := make([]*matched, 0, len(byKey))
for _, m := range byKey {
if sel.matches(m) {
out = append(out, m)
}
}
return out, nil
}
type groupAcc struct {
labels map[string]string
steps map[int64]*Agg
lastSum map[int64]float64
}
// collect loads all matching data in [from, to] and folds it into groups and
// step buckets chosen by bucketFn. Gauge "last" values are summed across the
// series of a group (e.g. queue depth over all instances).
func collect(sel Selector, from, to, res int64, bucketFn func(int64) int64) (map[string]*groupAcc, Kind, []float64, error) {
series, err := findSeries(sel)
if err != nil {
return nil, "", nil, err
}
if len(series) == 0 {
return map[string]*groupAcc{}, "", nil, nil
}
kind, bounds := series[0].kind, series[0].bounds
perSeries := map[*matched]map[int64]*Agg{}
add := func(m *matched, ts int64, a *Agg) {
b := bucketFn(ts)
steps := perSeries[m]
if steps == nil {
steps = map[int64]*Agg{}
perSeries[m] = steps
}
if cur := steps[b]; cur != nil {
cur.merge(a)
} else {
steps[b] = a.clone()
}
}
byID := map[uint]*matched{}
var ids []uint
for _, m := range series {
if m.id != 0 {
byID[m.id] = m
ids = append(ids, m.id)
}
}
load := func(resolution int, lo, hi int64) error {
if lo > hi || len(ids) == 0 {
return nil
}
for start := 0; start < len(ids); start += 300 {
end := min(start+300, len(ids))
var rows []models.MetricPoint
err := initializers.DB.
Where("resolution = ? AND series_id IN ? AND bucket >= ? AND bucket <= ?", resolution, ids[start:end], lo, hi).
Find(&rows).Error
if err != nil {
return err
}
for i := range rows {
add(byID[rows[i].SeriesID], rows[i].Bucket, pointToAgg(&rows[i]))
}
}
return nil
}
if res == resHour {
cut := rolledUntil.Load()
if err := load(resHour, hourOf(from), cut-1); err != nil {
return nil, "", nil, err
}
if err := load(resMinute, max(cut, from), to); err != nil {
return nil, "", nil, err
}
} else if err := load(resMinute, minuteOf(from), to); err != nil {
return nil, "", nil, err
}
// not yet flushed buckets
live := map[*Series]*matched{}
want := map[*Series]bool{}
for _, m := range series {
if m.live != nil {
live[m.live] = m
want[m.live] = true
}
}
for k, a := range defaultAgg.snapshotOpen(want) {
if k.bucket >= minuteOf(from) && k.bucket <= to {
add(live[k.s], k.bucket, a)
}
}
groups := map[string]*groupAcc{}
for m, steps := range perSeries {
labels := make(map[string]string, len(sel.GroupBy))
parts := make([]string, len(sel.GroupBy))
for i, g := range sel.GroupBy {
labels[g] = m.label(g)
parts[i] = labels[g]
}
gk := strings.Join(parts, "\x00")
g := groups[gk]
if g == nil {
g = &groupAcc{labels: labels, steps: map[int64]*Agg{}, lastSum: map[int64]float64{}}
groups[gk] = g
}
for b, a := range steps {
if cur := g.steps[b]; cur != nil {
cur.merge(a)
} else {
g.steps[b] = a.clone()
}
g.lastSum[b] += a.Last
}
}
return groups, kind, bounds, nil
}
func evaluate(a *Agg, lastSum float64, agg string, kind Kind, bounds []float64, span int64) (float64, bool) {
if a == nil || a.Count == 0 {
return 0, false
}
switch agg {
case "count":
return float64(a.Count), true
case "sum":
return a.Sum, true
case "rate":
if span <= 0 {
return 0, false
}
if kind == Counter {
return a.Sum / float64(span), true
}
return float64(a.Count) / float64(span), true
case "avg":
return a.Sum / float64(a.Count), true
case "min":
return a.Min, true
case "max":
return a.Max, true
case "last":
return lastSum, true
case "p50", "p90", "p95", "p99":
q, _ := strconv.ParseFloat(agg[1:], 64)
return quantile(a, bounds, q/100)
}
return 0, false
}
func pickResolution(from, to, step int64) int64 {
minuteHorizon := time.Now().Unix() - int64(initializers.MetricsMinuteRetentionDays)*86400
if to-from > 48*3600 || step >= resHour || from < minuteHorizon {
return resHour
}
return resMinute
}
// Query returns one time series per group with values at every step.
func Query(sel Selector, agg string, from, to, step int64) (*QueryResult, error) {
if sel.Name == "" {
return nil, fmt.Errorf("name is required")
}
if to <= from {
return nil, fmt.Errorf("to must be after from")
}
res := pickResolution(from, to, step)
if step < res {
step = res
}
if (to-from)/step > maxQueryPoints {
step = (to - from) / maxQueryPoints
}
step = int64(math.Ceil(float64(step)/float64(res))) * res
align := func(ts int64) int64 { return ts - ((ts%step)+step)%step }
groups, kind, bounds, err := collect(sel, from, to, res, align)
if err != nil {
return nil, err
}
if agg == "" {
agg = DefaultAgg(kind)
}
if !validAgg(agg) {
return nil, fmt.Errorf("unknown aggregation %q", agg)
}
result := &QueryResult{Name: sel.Name, Kind: kind, Agg: agg, Resolution: res, Step: step, From: from, To: to, Series: []SeriesResult{}}
keys := make([]string, 0, len(groups))
for k := range groups {
keys = append(keys, k)
}
sort.Strings(keys)
if len(keys) > maxQuerySeries {
keys = keys[:maxQuerySeries]
result.Truncated = true
}
for _, k := range keys {
g := groups[k]
sr := SeriesResult{Labels: g.labels}
for b := align(from); b <= to; b += step {
p := Point{T: b}
if v, ok := evaluate(g.steps[b], g.lastSum[b], agg, kind, bounds, step); ok && !math.IsNaN(v) && !math.IsInf(v, 0) {
p.V = &v
}
sr.Points = append(sr.Points, p)
}
result.Series = append(result.Series, sr)
}
return result, nil
}
// Total is the whole-range aggregate of one group.
type Total struct {
Labels map[string]string `json:"labels"`
Count int64 `json:"count"`
Sum float64 `json:"sum"`
Min *float64 `json:"min"`
Max *float64 `json:"max"`
Avg *float64 `json:"avg"`
Last *float64 `json:"last"`
Rate *float64 `json:"rate"`
P50 *float64 `json:"p50"`
P95 *float64 `json:"p95"`
P99 *float64 `json:"p99"`
}
// Totals aggregates [from, to] into one value set per group.
func Totals(sel Selector, from, to int64) ([]Total, error) {
res := pickResolution(from, to, 0)
groups, kind, bounds, err := collect(sel, from, to, res, func(int64) int64 { return 0 })
if err != nil {
return nil, err
}
span := to - from
out := make([]Total, 0, len(groups))
for _, g := range groups {
a := g.steps[0]
if a == nil {
continue
}
t := Total{Labels: g.labels, Count: a.Count, Sum: a.Sum}
val := func(agg string) *float64 {
if v, ok := evaluate(a, g.lastSum[0], agg, kind, bounds, span); ok && !math.IsNaN(v) && !math.IsInf(v, 0) {
return &v
}
return nil
}
t.Min, t.Max, t.Avg, t.Last, t.Rate = val("min"), val("max"), val("avg"), val("last"), val("rate")
t.P50, t.P95, t.P99 = val("p50"), val("p95"), val("p99")
out = append(out, t)
}
sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
return out, nil
}
// Sum returns the summed value of a counter (or count of observations for
// other kinds) over the range, across all matching series.
func Sum(sel Selector, from, to int64) float64 {
sel.GroupBy = nil
totals, err := Totals(sel, from, to)
if err != nil || len(totals) == 0 {
return 0
}
if f := familyByName(sel.Name); f != nil && f.Kind != Counter {
return float64(totals[0].Count)
}
return totals[0].Sum
}
// CatalogEntry describes one metric name of one service.
type CatalogEntry struct {
Service string `json:"service"`
Name string `json:"name"`
Kind Kind `json:"kind"`
Help string `json:"help"`
Labels map[string][]string `json:"labels"`
Instances []string `json:"instances"`
Series int `json:"series"`
LastSeen int64 `json:"lastSeen"`
}
// Catalog lists every metric known in the DB or in memory.
func Catalog(service string) ([]CatalogEntry, error) {
type acc struct {
e CatalogEntry
labels map[string]map[string]bool
inst map[string]bool
keys map[seriesKey]bool
}
entries := map[string]*acc{}
addSeries := func(svc, inst, name string, kind Kind, help, labelsJSON string, labels map[string]string, lastSeen int64) {
k := svc + "/" + name
a := entries[k]
if a == nil {
a = &acc{e: CatalogEntry{Service: svc, Name: name, Kind: kind, Help: help}, labels: map[string]map[string]bool{}, inst: map[string]bool{}, keys: map[seriesKey]bool{}}
entries[k] = a
}
if a.e.Help == "" {
a.e.Help = help
}
sk := seriesKey{svc, inst, name, labelsJSON}
if !a.keys[sk] {
a.keys[sk] = true
a.e.Series++
}
a.inst[inst] = true
for lk, lv := range labels {
if a.labels[lk] == nil {
a.labels[lk] = map[string]bool{}
}
a.labels[lk][lv] = true
}
if lastSeen > a.e.LastSeen {
a.e.LastSeen = lastSeen
}
}
q := initializers.DB.Model(&models.MetricSeries{})
if service != "" {
q = q.Where("service = ?", service)
}
var rows []models.MetricSeries
if err := q.Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
labels := map[string]string{}
_ = json.Unmarshal([]byte(r.Labels), &labels)
addSeries(r.Service, r.Instance, r.Name, Kind(r.Kind), r.Help, r.Labels, labels, r.LastSeen)
}
for _, s := range reg.all() {
if service != "" && s.Service != service {
continue
}
addSeries(s.Service, s.Instance, s.Name, s.Kind, s.Help, s.LabelsJSON, s.Labels, reg.seen(s))
}
out := make([]CatalogEntry, 0, len(entries))
for _, a := range entries {
a.e.Labels = map[string][]string{}
for lk, vals := range a.labels {
list := make([]string, 0, len(vals))
for v := range vals {
list = append(list, v)
}
sort.Strings(list)
a.e.Labels[lk] = list
}
for inst := range a.inst {
a.e.Instances = append(a.e.Instances, inst)
}
sort.Strings(a.e.Instances)
out = append(out, a.e)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Service != out[j].Service {
return out[i].Service < out[j].Service
}
return out[i].Name < out[j].Name
})
return out, nil
}
+284
View File
@@ -0,0 +1,284 @@
package metrics
import (
"encoding/json"
"errors"
"os"
"sort"
"strings"
"sync"
"time"
)
type Kind string
const (
Counter Kind = "counter" // value is an increment; Sum holds the total
Gauge Kind = "gauge" // value is a sample; Last holds the latest
Summary Kind = "summary" // pre-aggregated count/sum/min/max from a client
Histogram Kind = "histogram" // observations bucketed by Bounds
)
// MainService is the service name the API uses for its own metrics.
const MainService = "main"
var (
errSeriesLimit = errors.New("series limit reached")
errKindMismatch = errors.New("metric reported with a different type")
)
// Limits for series created from external telemetry.
const (
maxSeriesPerName = 200
maxExternal = 5000
)
var hostname = func() string {
h, err := os.Hostname()
if err != nil || h == "" {
return "api"
}
return h
}()
// Instance returns the instance name used for this process' own metrics.
func Instance() string { return hostname }
// Series is one metric name + label set from one service instance. Series are
// interned, so pointer equality means same series.
type Series struct {
Service string
Instance string
Name string
Labels map[string]string
LabelsJSON string
Kind Kind
Bounds []float64
Help string
}
type seriesKey struct{ service, instance, name, labels string }
func (s *Series) key() seriesKey {
return seriesKey{s.Service, s.Instance, s.Name, s.LabelsJSON}
}
// Label returns a label value, including the service/instance pseudo labels.
func (s *Series) Label(name string) string {
switch name {
case "service":
if v, ok := s.Labels["service"]; ok {
return v
}
return s.Service
case "instance":
if v, ok := s.Labels["instance"]; ok {
return v
}
return s.Instance
}
return s.Labels[name]
}
// canonicalLabels serializes labels deterministically (encoding/json sorts map keys).
func canonicalLabels(labels map[string]string) string {
if len(labels) == 0 {
return "{}"
}
b, _ := json.Marshal(labels)
return string(b)
}
type registry struct {
mu sync.RWMutex
series map[seriesKey]*Series
perName map[string]int // external series per service/name
external int
lastSeen map[*Series]int64
}
var reg = &registry{
series: make(map[seriesKey]*Series),
perName: make(map[string]int),
lastSeen: make(map[*Series]int64),
}
func (r *registry) get(service, instance, name string, labels map[string]string, kind Kind, bounds []float64, help string, external bool) (*Series, error) {
lj := canonicalLabels(labels)
k := seriesKey{service, instance, name, lj}
r.mu.RLock()
s := r.series[k]
r.mu.RUnlock()
if s != nil {
if s.Kind != kind {
return nil, errKindMismatch
}
return s, nil
}
r.mu.Lock()
defer r.mu.Unlock()
if s = r.series[k]; s != nil {
return s, nil
}
nameKey := service + "/" + name
if external {
if r.external >= maxExternal || r.perName[nameKey] >= maxSeriesPerName {
return nil, errSeriesLimit
}
r.external++
r.perName[nameKey]++
}
copied := make(map[string]string, len(labels))
for k, v := range labels {
copied[k] = v
}
s = &Series{
Service: service,
Instance: instance,
Name: name,
Labels: copied,
LabelsJSON: lj,
Kind: kind,
Bounds: bounds,
Help: help,
}
r.series[k] = s
return s, nil
}
func (r *registry) lookup(k seriesKey) *Series {
r.mu.RLock()
defer r.mu.RUnlock()
return r.series[k]
}
// all returns every known series, sorted by name then labels.
func (r *registry) all() []*Series {
r.mu.RLock()
out := make([]*Series, 0, len(r.series))
for _, s := range r.series {
out = append(out, s)
}
r.mu.RUnlock()
sort.Slice(out, func(i, j int) bool {
if out[i].Name != out[j].Name {
return out[i].Name < out[j].Name
}
if out[i].Service != out[j].Service {
return out[i].Service < out[j].Service
}
if out[i].Instance != out[j].Instance {
return out[i].Instance < out[j].Instance
}
return out[i].LabelsJSON < out[j].LabelsJSON
})
return out
}
func (r *registry) touch(s *Series, ts int64) {
r.mu.Lock()
if ts > r.lastSeen[s] {
r.lastSeen[s] = ts
}
r.mu.Unlock()
}
func (r *registry) seen(s *Series) int64 {
r.mu.RLock()
defer r.mu.RUnlock()
return r.lastSeen[s]
}
// Family is a metric emitted by this process, with a fixed set of label names.
type Family struct {
Name string
Help string
Kind Kind
Bounds []float64
LabelNames []string
mu sync.Mutex
cache map[string]*Series
}
var (
familiesMu sync.Mutex
families = map[string]*Family{}
)
func newFamily(name, help string, kind Kind, bounds []float64, labels []string) *Family {
f := &Family{Name: name, Help: help, Kind: kind, Bounds: bounds, LabelNames: labels, cache: map[string]*Series{}}
familiesMu.Lock()
families[name] = f
familiesMu.Unlock()
return f
}
func familyByName(name string) *Family {
familiesMu.Lock()
defer familiesMu.Unlock()
return families[name]
}
func NewCounter(name, help string, labels ...string) *Family {
return newFamily(name, help, Counter, nil, labels)
}
func NewGauge(name, help string, labels ...string) *Family {
return newFamily(name, help, Gauge, nil, labels)
}
func NewHistogram(name, help string, bounds []float64, labels ...string) *Family {
return newFamily(name, help, Histogram, bounds, labels)
}
func (f *Family) series(values []string) *Series {
key := strings.Join(values, "\x00")
f.mu.Lock()
defer f.mu.Unlock()
if s := f.cache[key]; s != nil {
return s
}
labels := make(map[string]string, len(f.LabelNames))
for i, n := range f.LabelNames {
if i < len(values) {
labels[n] = values[i]
} else {
labels[n] = ""
}
}
s, err := reg.get(MainService, Instance(), f.Name, labels, f.Kind, f.Bounds, f.Help, false)
if err != nil {
return nil
}
f.cache[key] = s
return s
}
// Add increments a counter. Zero increments are ignored.
func (f *Family) Add(v float64, labelValues ...string) {
if v == 0 {
return
}
if s := f.series(labelValues); s != nil {
defaultAgg.observe(s, time.Now().Unix(), v)
}
}
func (f *Family) Inc(labelValues ...string) { f.Add(1, labelValues...) }
// Set records a gauge sample.
func (f *Family) Set(v float64, labelValues ...string) {
if s := f.series(labelValues); s != nil {
defaultAgg.observe(s, time.Now().Unix(), v)
}
}
// Observe records a histogram observation.
func (f *Family) Observe(v float64, labelValues ...string) {
if s := f.series(labelValues); s != nil {
defaultAgg.observe(s, time.Now().Unix(), v)
}
}
+325
View File
@@ -0,0 +1,325 @@
package metrics
import (
"encoding/json"
"errors"
"fmt"
"math"
"regexp"
"sync"
"time"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/initializers"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/models"
"gorm.io/gorm/clause"
)
var nameRe = regexp.MustCompile(`^[a-z_][a-z0-9_]{0,63}$`)
// ValidName reports whether a service, metric or label name is acceptable.
func ValidName(s string) bool { return nameRe.MatchString(s) }
const (
defaultHeartbeat = 30
maxLabels = 8
maxLabelValue = 128
)
// normalizeTs converts unix seconds or milliseconds to seconds and clamps it
// to [now-1h, now+1m]; anything outside becomes now.
func normalizeTs(ts int64, now int64) int64 {
if ts <= 0 {
return now
}
if ts > 1e12 {
ts /= 1000
}
if ts < now-3600 || ts > now+60 {
return now
}
return ts
}
// IngestResult reports what happened to a telemetry batch.
type IngestResult struct {
Accepted int `json:"accepted"`
Dropped int `json:"dropped"`
Reasons map[string]int `json:"reasons,omitempty"`
}
// Ingest records external metric samples for a service instance.
func Ingest(service, instance string, samples []models.TelemetryMetric, now time.Time) IngestResult {
res := IngestResult{Reasons: map[string]int{}}
drop := func(reason string) {
res.Dropped++
res.Reasons[reason]++
}
nowTs := now.Unix()
for _, m := range samples {
if !ValidName(m.Name) {
drop("invalid_name")
continue
}
if len(m.Labels) > maxLabels {
drop("too_many_labels")
continue
}
badLabel := false
for k, v := range m.Labels {
if !ValidName(k) || len(v) > maxLabelValue {
badLabel = true
break
}
}
if badLabel {
drop("invalid_label")
continue
}
var kind Kind
switch m.Type {
case "counter":
kind = Counter
case "gauge", "":
kind = Gauge
case "summary":
kind = Summary
default:
drop("invalid_type")
continue
}
if math.IsNaN(m.Value) || math.IsInf(m.Value, 0) || math.IsNaN(m.Sum) || math.IsInf(m.Sum, 0) {
drop("invalid_value")
continue
}
if kind == Counter && m.Value < 0 {
drop("negative_counter")
continue
}
if kind == Summary && m.Count <= 0 {
drop("empty_summary")
continue
}
help := m.Help
if len(help) > 256 {
help = help[:256]
}
s, err := reg.get(service, instance, m.Name, m.Labels, kind, nil, help, true)
if err != nil {
if errors.Is(err, errKindMismatch) {
drop("type_mismatch")
} else {
drop("series_limit")
}
continue
}
ts := normalizeTs(m.Ts, nowTs)
if kind == Summary {
lo, hi := m.Min, m.Max
if lo == 0 && hi == 0 {
avg := m.Sum / float64(m.Count)
lo, hi = avg, avg
}
defaultAgg.observeSummary(s, ts, m.Count, m.Sum, lo, hi)
} else {
defaultAgg.observe(s, ts, m.Value)
}
res.Accepted++
}
TelemetrySamples.Add(float64(res.Accepted), "accepted")
TelemetrySamples.Add(float64(res.Dropped), "dropped")
return res
}
func jsonString(v map[string]any) string {
if len(v) == 0 {
return ""
}
b, err := json.Marshal(v)
if err != nil {
return ""
}
return string(b)
}
func truncate(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
// Heartbeat upserts the service instance row.
func Heartbeat(b *models.TelemetryBatch, remoteAddr string, now time.Time) error {
interval := b.HeartbeatIntervalSec
if interval <= 0 {
interval = defaultHeartbeat
}
status := b.Status
if status == "" {
status = "ok"
}
row := models.ServiceInstance{
Service: b.Service,
Instance: b.Instance,
Version: truncate(b.Version, 64),
Status: truncate(status, 16),
StatusMessage: truncate(b.StatusMessage, 512),
StartedAt: normalizeStarted(b.StartedAt),
LastSeen: now.Unix(),
HeartbeatIntervalSec: interval,
RemoteAddr: truncate(remoteAddr, 64),
Meta: jsonString(b.Meta),
}
return initializers.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "service"}, {Name: "instance"}},
DoUpdates: clause.AssignmentColumns([]string{
"version", "status", "status_message", "started_at", "last_seen",
"heartbeat_interval_sec", "remote_addr", "meta", "updated_at",
}),
}).Create(&row).Error
}
func normalizeStarted(ts int64) int64 {
if ts > 1e12 {
return ts / 1000
}
return ts
}
// RecordEvents stores service events.
func RecordEvents(service, instance string, events []models.TelemetryEvent, now time.Time) error {
if len(events) == 0 {
return nil
}
rows := make([]models.ServiceEvent, 0, len(events))
for _, e := range events {
level := e.Level
switch level {
case "debug", "info", "warn", "error":
case "warning":
level = "warn"
default:
level = "info"
}
ts := e.Ts
if ts <= 0 {
ts = now.UnixMilli()
} else if ts < 1e12 {
ts *= 1000
}
rows = append(rows, models.ServiceEvent{
Service: service, Instance: instance, Level: level,
Message: truncate(e.Message, 1024), Fields: jsonString(e.Fields), TS: ts,
})
}
return initializers.DB.CreateInBatches(&rows, 100).Error
}
// Event records a single event from the API itself.
func Event(level, message string, fields map[string]any) {
err := RecordEvents(MainService, Instance(), []models.TelemetryEvent{{Level: level, Message: message, Fields: fields}}, time.Now())
if err != nil {
fmt.Printf("metrics: failed to record event: %v\n", err)
}
}
// Online reports whether an instance has sent a heartbeat recently enough.
func Online(inst *models.ServiceInstance, now time.Time) bool {
interval := inst.HeartbeatIntervalSec
if interval <= 0 {
interval = defaultHeartbeat
}
return now.Unix()-inst.LastSeen < int64(3*interval)
}
var (
upMu sync.Mutex
upState = map[string]bool{} // "service/instance" -> online
)
// ServiceUp returns the last known online state of every instance.
func ServiceUp() map[[2]string]bool {
upMu.Lock()
defer upMu.Unlock()
out := make(map[[2]string]bool, len(upState))
for k, v := range upState {
var svc, inst string
for i := 0; i < len(k); i++ {
if k[i] == '/' {
svc, inst = k[:i], k[i+1:]
break
}
}
out[[2]string{svc, inst}] = v
}
return out
}
// checkServices records online/offline transitions as events.
func checkServices(now time.Time) {
var rows []models.ServiceInstance
if err := initializers.DB.Find(&rows).Error; err != nil {
return
}
upMu.Lock()
known := make(map[string]bool, len(rows))
type change struct {
inst models.ServiceInstance
online bool
}
var changes []change
for _, r := range rows {
k := r.Service + "/" + r.Instance
known[k] = true
online := Online(&r, now)
prev, seen := upState[k]
upState[k] = online
if seen && prev != online {
changes = append(changes, change{r, online})
}
}
for k := range upState {
if !known[k] {
delete(upState, k)
}
}
upMu.Unlock()
for _, ch := range changes {
level, msg := "info", "back online"
if !ch.online {
level, msg = "warn", "heartbeat lost"
}
_ = RecordEvents(ch.inst.Service, ch.inst.Instance, []models.TelemetryEvent{{
Level: level, Message: msg,
Fields: map[string]any{"kind": "status", "lastSeen": ch.inst.LastSeen},
}}, now)
}
}
var (
buildMu sync.Mutex
buildVersion = "dev"
startedAt = time.Now()
)
// SetVersion sets the version reported in the API's own heartbeat.
func SetVersion(v string) {
buildMu.Lock()
buildVersion = v
buildMu.Unlock()
}
// StartedAt is when this process started.
func StartedAt() time.Time { return startedAt }
func selfHeartbeat(now time.Time) {
buildMu.Lock()
v := buildVersion
buildMu.Unlock()
_ = Heartbeat(&models.TelemetryBatch{
Service: MainService, Instance: Instance(), Version: v,
StartedAt: startedAt.Unix(), HeartbeatIntervalSec: 30, Status: "ok",
}, "", now)
}
+371
View File
@@ -0,0 +1,371 @@
package metrics
import (
"encoding/json"
"fmt"
"log"
"sort"
"sync"
"sync/atomic"
"time"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/initializers"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
resMinute = 60
resHour = 3600
// points that cannot be written for this long are dropped
maxRetryAge = 15 * 60
)
var (
idMu sync.Mutex
seriesID = map[*Series]uint{}
// rolledUntil is the start of the first hour without an hourly rollup.
rolledUntil atomic.Int64
// flushMu serializes writers of metric_points (flush, rollup, retention).
flushMu sync.Mutex
)
func pointToAgg(p *models.MetricPoint) *Agg {
a := &Agg{Count: p.Count, Sum: p.Sum, Min: p.Min, Max: p.Max, Last: p.Last, LastTs: p.Bucket}
if p.Hist != "" {
_ = json.Unmarshal([]byte(p.Hist), &a.Hist)
}
return a
}
func aggToPoint(id uint, res int, bucket int64, a *Agg) models.MetricPoint {
return models.MetricPoint{
SeriesID: id, Resolution: res, Bucket: bucket,
Count: a.Count, Sum: a.Sum, Min: a.Min, Max: a.Max, Last: a.Last, Hist: a.histJSON(),
}
}
// resolveIDs makes sure every series has a metric_series row.
func resolveIDs(list []*Series) (map[*Series]uint, error) {
out := make(map[*Series]uint, len(list))
var missing []*Series
idMu.Lock()
for _, s := range list {
if id, ok := seriesID[s]; ok {
out[s] = id
} else {
missing = append(missing, s)
}
}
idMu.Unlock()
for _, s := range missing {
bounds := ""
if len(s.Bounds) > 0 {
b, _ := json.Marshal(s.Bounds)
bounds = string(b)
}
row := models.MetricSeries{
Service: s.Service, Instance: s.Instance, Name: s.Name, Labels: s.LabelsJSON,
Kind: string(s.Kind), Bounds: bounds, Help: s.Help, LastSeen: time.Now().Unix(),
}
if err := initializers.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
return nil, err
}
if row.ID == 0 {
if err := initializers.DB.
Where("service = ? AND instance = ? AND name = ? AND labels = ?", s.Service, s.Instance, s.Name, s.LabelsJSON).
First(&row).Error; err != nil {
return nil, err
}
}
idMu.Lock()
seriesID[s] = row.ID
idMu.Unlock()
out[s] = row.ID
}
return out, nil
}
type pointID struct {
id uint
bucket int64
}
// persist merges the given buckets into existing rows and upserts them. The
// read-merge-write runs in one transaction; this process is the only writer.
func persist(tx *gorm.DB, res int, points map[pointID]*Agg) error {
if len(points) == 0 {
return nil
}
keys := make([]pointID, 0, len(points))
for k := range points {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].id != keys[j].id {
return keys[i].id < keys[j].id
}
return keys[i].bucket < keys[j].bucket
})
for start := 0; start < len(keys); start += 400 {
end := min(start+400, len(keys))
chunk := keys[start:end]
idSet, bucketSet := map[uint]bool{}, map[int64]bool{}
for _, k := range chunk {
idSet[k.id] = true
bucketSet[k.bucket] = true
}
ids := make([]uint, 0, len(idSet))
for id := range idSet {
ids = append(ids, id)
}
buckets := make([]int64, 0, len(bucketSet))
for b := range bucketSet {
buckets = append(buckets, b)
}
var existing []models.MetricPoint
if err := tx.Where("resolution = ? AND series_id IN ? AND bucket IN ?", res, ids, buckets).Find(&existing).Error; err != nil {
return err
}
prev := make(map[pointID]*Agg, len(existing))
for i := range existing {
prev[pointID{existing[i].SeriesID, existing[i].Bucket}] = pointToAgg(&existing[i])
}
rows := make([]models.MetricPoint, 0, len(chunk))
for _, k := range chunk {
a := points[k]
if old := prev[k]; old != nil {
old.merge(a)
a = old
}
rows = append(rows, aggToPoint(k.id, res, k.bucket, a))
}
err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "series_id"}, {Name: "resolution"}, {Name: "bucket"}},
DoUpdates: clause.AssignmentColumns([]string{"count", "sum", "min", "max", "last", "hist"}),
}).CreateInBatches(&rows, 200).Error
if err != nil {
return err
}
}
return nil
}
type pipelineStatus struct {
mu sync.Mutex
LastFlush int64 `json:"lastFlush"`
LastFlushMs int64 `json:"lastFlushMs"`
LastFlushErr string `json:"lastFlushError"`
LastRollup int64 `json:"lastRollup"`
LastRetention int64 `json:"lastRetention"`
Dropped int64 `json:"droppedPoints"`
}
var status pipelineStatus
// flush writes every bucket that started before `before`.
func flush(before int64) error {
flushMu.Lock()
defer flushMu.Unlock()
started := time.Now()
points := defaultAgg.takeClosed(before)
if len(points) == 0 {
return nil
}
fail := func(err error) error {
now := time.Now().Unix()
keep := make(map[pointKey]*Agg)
var dropped int64
for k, a := range points {
if now-k.bucket <= maxRetryAge {
keep[k] = a
} else {
dropped++
}
}
defaultAgg.putBack(keep)
status.mu.Lock()
status.LastFlushErr = err.Error()
status.Dropped += dropped
status.mu.Unlock()
if dropped > 0 {
PointsDropped.Add(float64(dropped))
}
return err
}
set := map[*Series]bool{}
for k := range points {
set[k.s] = true
}
list := make([]*Series, 0, len(set))
for s := range set {
list = append(list, s)
}
ids, err := resolveIDs(list)
if err != nil {
return fail(fmt.Errorf("resolve series: %w", err))
}
rows := make(map[pointID]*Agg, len(points))
for k, a := range points {
rows[pointID{ids[k.s], k.bucket}] = a
}
err = initializers.DB.Transaction(func(tx *gorm.DB) error {
return persist(tx, resMinute, rows)
})
if err != nil {
return fail(fmt.Errorf("write points: %w", err))
}
// last_seen lets the catalog hide series that stopped reporting
idList := make([]uint, 0, len(ids))
for _, id := range ids {
idList = append(idList, id)
}
for start := 0; start < len(idList); start += 500 {
end := min(start+500, len(idList))
initializers.DB.Model(&models.MetricSeries{}).Where("id IN ?", idList[start:end]).
Update("last_seen", time.Now().Unix())
}
status.mu.Lock()
status.LastFlush = time.Now().Unix()
status.LastFlushMs = time.Since(started).Milliseconds()
status.LastFlushErr = ""
status.mu.Unlock()
return nil
}
func hourOf(ts int64) int64 { return ts - ts%resHour }
// rollup aggregates complete hours of minute data into hourly points. The last
// rolled hour is redone each time to pick up late telemetry; it is idempotent.
func rollup(now int64) error {
flushMu.Lock()
defer flushMu.Unlock()
// the minute data of an hour is complete ~1 flush after the hour ends
lastFull := hourOf(now - 2*resMinute)
start := rolledUntil.Load()
if start == 0 {
var maxHour, minMinute *int64
initializers.DB.Model(&models.MetricPoint{}).Where("resolution = ?", resHour).Select("MAX(bucket)").Scan(&maxHour)
if maxHour != nil {
start = *maxHour + resHour
} else {
initializers.DB.Model(&models.MetricPoint{}).Where("resolution = ?", resMinute).Select("MIN(bucket)").Scan(&minMinute)
if minMinute == nil {
status.mu.Lock()
status.LastRollup = now
status.mu.Unlock()
return nil
}
start = hourOf(*minMinute)
}
}
from := start - resHour
if earliest := hourOf(now - int64(initializers.MetricsMinuteRetentionDays)*86400); from < earliest {
from = earliest
}
for h := from; h < lastFull; h += resHour {
var rows []models.MetricPoint
err := initializers.DB.Where("resolution = ? AND bucket >= ? AND bucket < ?", resMinute, h, h+resHour).
Order("bucket asc").Find(&rows).Error
if err != nil {
return err
}
if len(rows) == 0 {
continue
}
merged := map[pointID]*Agg{}
for i := range rows {
k := pointID{rows[i].SeriesID, h}
a := merged[k]
if a == nil {
a = &Agg{}
merged[k] = a
}
a.merge(pointToAgg(&rows[i]))
}
// replace (not merge) so re-rolling an hour is idempotent
out := make([]models.MetricPoint, 0, len(merged))
for k, a := range merged {
out = append(out, aggToPoint(k.id, resHour, k.bucket, a))
}
err = initializers.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "series_id"}, {Name: "resolution"}, {Name: "bucket"}},
DoUpdates: clause.AssignmentColumns([]string{"count", "sum", "min", "max", "last", "hist"}),
}).CreateInBatches(&out, 200).Error
if err != nil {
return err
}
}
if lastFull > rolledUntil.Load() {
rolledUntil.Store(lastFull)
}
status.mu.Lock()
status.LastRollup = now
status.mu.Unlock()
return nil
}
// retention deletes data older than the configured windows.
func retention(now time.Time) error {
flushMu.Lock()
defer flushMu.Unlock()
day := int64(86400)
ts := now.Unix()
steps := []struct {
what string
run func() error
}{
{"minute points", func() error {
return initializers.DB.Where("resolution = ? AND bucket < ?", resMinute, ts-int64(initializers.MetricsMinuteRetentionDays)*day).
Delete(&models.MetricPoint{}).Error
}},
{"hour points", func() error {
return initializers.DB.Where("resolution = ? AND bucket < ?", resHour, ts-int64(initializers.MetricsHourRetentionDays)*day).
Delete(&models.MetricPoint{}).Error
}},
{"events", func() error {
return initializers.DB.Where("ts < ?", (ts-int64(initializers.EventsRetentionDays)*day)*1000).
Delete(&models.ServiceEvent{}).Error
}},
{"top items", func() error {
cutoff := now.UTC().AddDate(0, 0, -initializers.TopItemsRetentionDays).Format("2006-01-02")
return initializers.DB.Where("day < ?", cutoff).Delete(&models.DailyTopItem{}).Error
}},
{"stale series", func() error {
stale := ts - 30*day
res := initializers.DB.Where("last_seen < ? AND id NOT IN (SELECT DISTINCT series_id FROM metric_points)", stale).
Delete(&models.MetricSeries{})
if res.Error == nil && res.RowsAffected > 0 {
// forget cached ids so a revived series gets a new row
idMu.Lock()
seriesID = map[*Series]uint{}
idMu.Unlock()
}
return res.Error
}},
}
for _, step := range steps {
if err := step.run(); err != nil {
log.Printf("metrics retention (%s): %v", step.what, err)
}
}
status.mu.Lock()
status.LastRetention = now.Unix()
status.mu.Unlock()
return nil
}
+328
View File
@@ -0,0 +1,328 @@
package metrics
import (
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"log"
"sort"
"strconv"
"strings"
"sync"
"time"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/initializers"
"gitea.tbdevent.eu/TBD/reforger_crawler_main/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm/clause"
)
// Middleware records latency and traffic for every request except static admin
// UI files and health checks.
func Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
if c.Request.Method == "OPTIONS" || path == "/health" || strings.HasPrefix(path, "/admin/ui") {
c.Next()
return
}
start := time.Now()
c.Next()
ms := float64(time.Since(start).Microseconds()) / 1000
route := c.FullPath()
if route == "" {
route = "(unmatched)"
}
status := c.Writer.Status()
group := routeGroup(path)
client := "direct"
if c.GetHeader("Origin") != "" || c.GetHeader("Referer") != "" {
client = "web"
}
HTTPDuration.Observe(ms, c.Request.Method, route, strconv.Itoa(status/100)+"xx", group, client)
if size := c.Writer.Size(); size > 0 {
HTTPResponseBytes.Add(float64(size), group)
}
if group == "v1" && status < 500 {
visitors.add(c.ClientIP(), c.Request.UserAgent())
}
if status == 200 {
switch route {
case "/v1/addon/:id":
Top.Add("addon", c.Param("id"))
case "/v1/getPossible":
Top.Add("search", normalizeSearch(c.Query("q")))
}
}
}
}
func routeGroup(path string) string {
for _, g := range []string{"v1", "back", "admin", "metrics"} {
if path == "/"+g || strings.HasPrefix(path, "/"+g+"/") {
return g
}
}
return "other"
}
func normalizeSearch(q string) string {
q = strings.Join(strings.Fields(strings.ToLower(q)), " ")
if len(q) > 64 {
q = q[:64]
}
return q
}
// visitorSet counts distinct (IP, user agent) pairs per UTC day. Only salted
// 64-bit hashes are kept and the salt never leaves memory.
type visitorSet struct {
mu sync.Mutex
day string
salt []byte
humans map[uint64]struct{}
bots map[uint64]struct{}
}
var visitors = &visitorSet{}
const maxVisitors = 2_000_000
func (v *visitorSet) rotate(day string) {
v.day = day
v.salt = make([]byte, 32)
_, _ = rand.Read(v.salt)
v.humans = make(map[uint64]struct{})
v.bots = make(map[uint64]struct{})
}
func isBot(ua string) bool {
ua = strings.ToLower(ua)
if ua == "" {
return true
}
for _, s := range []string{"bot", "spider", "crawl", "curl", "wget", "python", "go-http", "headless", "preview", "monitor"} {
if strings.Contains(ua, s) {
return true
}
}
return false
}
func (v *visitorSet) add(ip, ua string) {
day := time.Now().UTC().Format("2006-01-02")
v.mu.Lock()
defer v.mu.Unlock()
if v.day != day {
v.rotate(day)
}
h := sha256.New()
h.Write(v.salt)
h.Write([]byte(ip))
h.Write([]byte{0})
h.Write([]byte(ua))
key := binary.BigEndian.Uint64(h.Sum(nil)[:8])
set := v.humans
if isBot(ua) {
set = v.bots
}
if len(set) < maxVisitors {
set[key] = struct{}{}
}
}
// Visitors returns today's approximate unique human and bot visitors. The set
// lives in memory, so after a restart the day's highest recorded value is used
// until the new set catches up.
func Visitors() (humans, bots int) {
now := time.Now().UTC()
day := now.Format("2006-01-02")
visitors.mu.Lock()
if visitors.day == day {
humans, bots = len(visitors.humans), len(visitors.bots)
}
visitors.mu.Unlock()
dayStart := now.Truncate(24 * time.Hour).Unix()
if dayStart < startedAt.Unix() {
if t, err := Totals(Selector{Service: MainService, Name: "visitors_unique", GroupBy: []string{"kind"}}, dayStart, now.Unix()); err == nil {
for _, g := range t {
if g.Max == nil {
continue
}
switch g.Labels["kind"] {
case "human":
humans = max(humans, int(*g.Max))
case "bot":
bots = max(bots, int(*g.Max))
}
}
}
}
return humans, bots
}
// liveVisitors is the in-memory count only, used when recording the gauge.
func liveVisitors() (humans, bots int) {
day := time.Now().UTC().Format("2006-01-02")
visitors.mu.Lock()
defer visitors.mu.Unlock()
if visitors.day != day {
return 0, 0
}
return len(visitors.humans), len(visitors.bots)
}
// topCounter counts requested items per UTC day and persists the most popular.
type topCounter struct {
mu sync.Mutex
day string
counts map[string]map[string]int64
pending []topSnapshot // previous days not yet written
}
type topSnapshot struct {
day string
counts map[string]map[string]int64
}
// Top tracks the most looked-up addons and search queries.
var Top = &topCounter{counts: map[string]map[string]int64{}}
const (
maxTopKeys = 50_000
topPersistMax = 500
)
func (t *topCounter) Add(kind, item string) {
if item == "" {
return
}
if len(item) > 128 {
item = item[:128]
}
day := time.Now().UTC().Format("2006-01-02")
t.mu.Lock()
defer t.mu.Unlock()
if t.day != day {
if t.day != "" {
t.pending = append(t.pending, topSnapshot{t.day, t.counts})
}
t.day = day
t.counts = map[string]map[string]int64{}
}
m := t.counts[kind]
if m == nil {
m = map[string]int64{}
t.counts[kind] = m
}
if _, ok := m[item]; !ok && len(m) >= maxTopKeys {
for k, v := range m {
if v <= 1 {
delete(m, k)
}
}
if len(m) >= maxTopKeys {
return
}
}
m[item]++
}
// Today returns today's in-memory counts for a kind.
func (t *topCounter) Today(kind string) (string, map[string]int64) {
t.mu.Lock()
defer t.mu.Unlock()
out := make(map[string]int64, len(t.counts[kind]))
for k, v := range t.counts[kind] {
out[k] = v
}
return t.day, out
}
// seed loads today's persisted counts so a restart does not reset them.
func (t *topCounter) seed() {
day := time.Now().UTC().Format("2006-01-02")
var rows []models.DailyTopItem
if err := initializers.DB.Where("day = ?", day).Find(&rows).Error; err != nil {
log.Printf("metrics: failed to load top items: %v", err)
return
}
t.mu.Lock()
defer t.mu.Unlock()
if t.day != day {
t.day = day
t.counts = map[string]map[string]int64{}
}
for _, r := range rows {
if t.counts[r.Kind] == nil {
t.counts[r.Kind] = map[string]int64{}
}
t.counts[r.Kind][r.Item] += r.Count
}
}
func topRows(snap topSnapshot) []models.DailyTopItem {
var rows []models.DailyTopItem
for kind, m := range snap.counts {
type kv struct {
k string
v int64
}
list := make([]kv, 0, len(m))
for k, v := range m {
list = append(list, kv{k, v})
}
sort.Slice(list, func(i, j int) bool { return list[i].v > list[j].v })
if len(list) > topPersistMax {
list = list[:topPersistMax]
}
for _, e := range list {
rows = append(rows, models.DailyTopItem{Day: snap.day, Kind: kind, Item: e.k, Count: e.v})
}
}
return rows
}
func (t *topCounter) flush() error {
t.mu.Lock()
snaps := append([]topSnapshot{}, t.pending...)
t.pending = nil
cur := topSnapshot{day: t.day, counts: map[string]map[string]int64{}}
for kind, m := range t.counts {
cp := make(map[string]int64, len(m))
for k, v := range m {
cp[k] = v
}
cur.counts[kind] = cp
}
t.mu.Unlock()
if cur.day != "" {
snaps = append(snaps, cur)
}
for i, snap := range snaps {
rows := topRows(snap)
if len(rows) == 0 {
continue
}
err := initializers.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "day"}, {Name: "kind"}, {Name: "item"}},
DoUpdates: clause.AssignmentColumns([]string{"count"}),
}).CreateInBatches(&rows, 200).Error
if err != nil {
// keep finished days for the next attempt; today is re-read from memory
t.mu.Lock()
for _, old := range snaps[i:] {
if old.day != cur.day {
t.pending = append(t.pending, old)
}
}
t.mu.Unlock()
return err
}
}
return nil
}