Files
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

431 lines
11 KiB
Go

// Package telemetry reports metrics, events and heartbeats from a crawler
// service (indexer, scraper, ...) to the main API's POST /back/telemetry.
//
// It has no dependencies outside the standard library, so it can be imported
// by any service, or the file can simply be copied.
//
// c := telemetry.New(telemetry.Config{
// BaseURL: "https://crawler.example.com",
// SecretKey: os.Getenv("SECRET_KEY"),
// Service: "indexer",
// Version: "1.4.0",
// })
// c.Start(ctx)
// defer c.Close(context.Background())
//
// c.Counter("jobs_total", "result", "ok").Add(1)
// c.Gauge("data_window_bytes").Set(float64(used))
// c.Summary("download_seconds").Observe(took.Seconds())
// c.Event(telemetry.Warn, "data limit reached", map[string]any{"limitGB": 50})
// c.SetStatus(telemetry.Degraded, "rate limited by workshop")
//
// Recording never blocks on the network. A push that fails keeps the counter
// increments for the next attempt; a few failed batches of events and samples
// are buffered and the oldest are dropped first.
package telemetry
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
)
// Level of an event.
type Level string
const (
Debug Level = "debug"
Info Level = "info"
Warn Level = "warn"
Error Level = "error"
)
// Status is the self-reported health shown on the dashboard.
type Status string
const (
OK Status = "ok"
Degraded Status = "degraded"
Failing Status = "error"
)
// Config configures a Client. BaseURL, SecretKey and Service are required.
type Config struct {
BaseURL string // e.g. https://crawler.example.com (no trailing /back)
SecretKey string // the API's SECRET_KEY
Service string // [a-z0-9_], e.g. "indexer"
Instance string // defaults to the hostname
Version string // shown on the dashboard
Interval time.Duration // push interval, default 15s (also the heartbeat)
Meta map[string]any
// HTTPClient defaults to a client with a 10s timeout.
HTTPClient *http.Client
// OnError is called when a push fails (optional).
OnError func(error)
}
// Client buffers metrics and events and pushes them periodically.
type Client struct {
cfg Config
started time.Time
mu sync.Mutex
counters map[string]*Counter
gauges map[string]*Gauge
summaries map[string]*Summary
events []event
status Status
statusMsg string
pending [][]byte // failed batches waiting for a retry
stop chan struct{}
done chan struct{}
}
type metric struct {
Name string `json:"name"`
Type string `json:"type"`
Value float64 `json:"value,omitempty"`
Count int64 `json:"count,omitempty"`
Sum float64 `json:"sum,omitempty"`
Min float64 `json:"min,omitempty"`
Max float64 `json:"max,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Ts int64 `json:"ts"`
}
type event struct {
Level Level `json:"level"`
Message string `json:"message"`
Ts int64 `json:"ts"`
Fields map[string]any `json:"fields,omitempty"`
}
type batch struct {
Service string `json:"service"`
Instance string `json:"instance"`
Version string `json:"version"`
StartedAt int64 `json:"startedAt"`
HeartbeatIntervalSec int `json:"heartbeatIntervalSec"`
Status Status `json:"status"`
StatusMessage string `json:"statusMessage"`
Meta map[string]any `json:"meta,omitempty"`
Metrics []metric `json:"metrics"`
Events []event `json:"events"`
}
const (
maxPending = 20
maxEvents = 200
maxMetricsPerReq = 1000
)
// New creates a client. Call Start to begin pushing.
func New(cfg Config) *Client {
if cfg.Instance == "" {
cfg.Instance, _ = os.Hostname()
}
if cfg.Interval <= 0 {
cfg.Interval = 15 * time.Second
}
if cfg.HTTPClient == nil {
cfg.HTTPClient = &http.Client{Timeout: 10 * time.Second}
}
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
return &Client{
cfg: cfg,
started: time.Now(),
counters: map[string]*Counter{},
gauges: map[string]*Gauge{},
summaries: map[string]*Summary{},
status: OK,
}
}
func key(name string, labels []string) string {
return name + "\x00" + strings.Join(labels, "\x00")
}
func labelMap(kv []string) map[string]string {
if len(kv) < 2 {
return nil
}
m := make(map[string]string, len(kv)/2)
for i := 0; i+1 < len(kv); i += 2 {
m[kv[i]] = kv[i+1]
}
return m
}
// Counter returns a counter; labels are key/value pairs.
func (c *Client) Counter(name string, labels ...string) *Counter {
k := key(name, labels)
c.mu.Lock()
defer c.mu.Unlock()
ctr := c.counters[k]
if ctr == nil {
ctr = &Counter{name: name, labels: labelMap(labels)}
c.counters[k] = ctr
}
return ctr
}
// Gauge returns a gauge; labels are key/value pairs.
func (c *Client) Gauge(name string, labels ...string) *Gauge {
k := key(name, labels)
c.mu.Lock()
defer c.mu.Unlock()
g := c.gauges[k]
if g == nil {
g = &Gauge{name: name, labels: labelMap(labels)}
c.gauges[k] = g
}
return g
}
// Summary returns a summary (count/sum/min/max per push); labels are key/value pairs.
func (c *Client) Summary(name string, labels ...string) *Summary {
k := key(name, labels)
c.mu.Lock()
defer c.mu.Unlock()
s := c.summaries[k]
if s == nil {
s = &Summary{name: name, labels: labelMap(labels)}
c.summaries[k] = s
}
return s
}
// Event queues a log event for the dashboard.
func (c *Client) Event(level Level, message string, fields map[string]any) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.events) >= maxEvents {
c.events = c.events[1:]
}
c.events = append(c.events, event{Level: level, Message: message, Ts: time.Now().UnixMilli(), Fields: fields})
}
// SetStatus sets the self-reported health shown on the dashboard.
func (c *Client) SetStatus(s Status, message string) {
c.mu.Lock()
c.status, c.statusMsg = s, message
c.mu.Unlock()
}
// Counter accumulates increments between pushes.
type Counter struct {
mu sync.Mutex
name string
labels map[string]string
delta float64
}
func (m *Counter) Add(v float64) {
if v <= 0 || math.IsNaN(v) || math.IsInf(v, 0) {
return
}
m.mu.Lock()
m.delta += v
m.mu.Unlock()
}
func (m *Counter) Inc() { m.Add(1) }
// Gauge holds the latest value.
type Gauge struct {
mu sync.Mutex
name string
labels map[string]string
value float64
set bool
}
func (m *Gauge) Set(v float64) {
if math.IsNaN(v) || math.IsInf(v, 0) {
return
}
m.mu.Lock()
m.value, m.set = v, true
m.mu.Unlock()
}
// Summary aggregates observations between pushes.
type Summary struct {
mu sync.Mutex
name string
labels map[string]string
count int64
sum float64
min, max float64
}
func (m *Summary) Observe(v float64) {
if math.IsNaN(v) || math.IsInf(v, 0) {
return
}
m.mu.Lock()
if m.count == 0 || v < m.min {
m.min = v
}
if m.count == 0 || v > m.max {
m.max = v
}
m.count++
m.sum += v
m.mu.Unlock()
}
// collect drains the current values into a batch payload.
func (c *Client) collect() []byte {
now := time.Now()
c.mu.Lock()
b := batch{
Service: c.cfg.Service,
Instance: c.cfg.Instance,
Version: c.cfg.Version,
StartedAt: c.started.Unix(),
HeartbeatIntervalSec: int(c.cfg.Interval.Seconds()),
Status: c.status,
StatusMessage: c.statusMsg,
Meta: c.cfg.Meta,
Events: c.events,
}
c.events = nil
counters := make([]*Counter, 0, len(c.counters))
for _, m := range c.counters {
counters = append(counters, m)
}
gauges := make([]*Gauge, 0, len(c.gauges))
for _, m := range c.gauges {
gauges = append(gauges, m)
}
summaries := make([]*Summary, 0, len(c.summaries))
for _, m := range c.summaries {
summaries = append(summaries, m)
}
c.mu.Unlock()
ts := now.UnixMilli()
for _, m := range counters {
m.mu.Lock()
if m.delta > 0 {
b.Metrics = append(b.Metrics, metric{Name: m.name, Type: "counter", Value: m.delta, Labels: m.labels, Ts: ts})
m.delta = 0
}
m.mu.Unlock()
}
for _, m := range gauges {
m.mu.Lock()
if m.set {
b.Metrics = append(b.Metrics, metric{Name: m.name, Type: "gauge", Value: m.value, Labels: m.labels, Ts: ts})
}
m.mu.Unlock()
}
for _, m := range summaries {
m.mu.Lock()
if m.count > 0 {
b.Metrics = append(b.Metrics, metric{Name: m.name, Type: "summary", Count: m.count, Sum: m.sum, Min: m.min, Max: m.max, Labels: m.labels, Ts: ts})
m.count, m.sum, m.min, m.max = 0, 0, 0, 0
}
m.mu.Unlock()
}
sort.Slice(b.Metrics, func(i, j int) bool { return b.Metrics[i].Name < b.Metrics[j].Name })
if len(b.Metrics) > maxMetricsPerReq {
b.Metrics = b.Metrics[:maxMetricsPerReq]
}
if b.Metrics == nil {
b.Metrics = []metric{}
}
if b.Events == nil {
b.Events = []event{}
}
payload, _ := json.Marshal(b)
return payload
}
func (c *Client) post(ctx context.Context, payload []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.BaseURL+"/back/telemetry", bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-SECRET-KEY", c.cfg.SecretKey)
resp, err := c.cfg.HTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode/100 != 2 {
return fmt.Errorf("telemetry push: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
return nil
}
// Flush pushes buffered batches and the current values now.
func (c *Client) Flush(ctx context.Context) error {
payload := c.collect()
c.mu.Lock()
queue := append(c.pending, payload)
c.pending = nil
c.mu.Unlock()
for i, p := range queue {
if err := c.post(ctx, p); err != nil {
c.mu.Lock()
c.pending = append(queue[i:], c.pending...)
if len(c.pending) > maxPending {
c.pending = c.pending[len(c.pending)-maxPending:]
}
c.mu.Unlock()
if c.cfg.OnError != nil {
c.cfg.OnError(err)
}
return err
}
}
return nil
}
// Start pushes in the background every Interval until ctx ends or Close is called.
func (c *Client) Start(ctx context.Context) {
c.stop = make(chan struct{})
c.done = make(chan struct{})
go func() {
defer close(c.done)
t := time.NewTicker(c.cfg.Interval)
defer t.Stop()
_ = c.Flush(ctx)
for {
select {
case <-ctx.Done():
return
case <-c.stop:
return
case <-t.C:
_ = c.Flush(ctx)
}
}
}()
}
// Close stops the background loop and makes a final push.
func (c *Client) Close(ctx context.Context) error {
if c.stop != nil {
close(c.stop)
<-c.done
c.stop = nil
}
return c.Flush(ctx)
}