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
372 lines
10 KiB
Go
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
|
|
}
|