Files
reforger_crawler_main/metrics/store.go
T
ilbinek 56d9e10621
Cross Compile Go / build (push) Canceled after 0s
admin dashboard, metrics and telemetry
- 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
2026-10-03 18:10:57 +02:00

372 lines
10 KiB
Go

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
}