This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/metrics/collector.go
Jeffrey Smith 36d6158940
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m42s
CI/CD / test-sqlite (push) Successful in 2m48s
CI/CD / build-and-deploy (push) Successful in 1m5s
Feat v0.6.4 health metrics (#39)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-31 14:05:49 +00:00

257 lines
7.2 KiB
Go

// Package metrics — collector.go
//
// On-demand metrics collector for the admin /api/v1/admin/metrics endpoint.
// Gathers runtime, database, cluster, and extension stats into a single JSON snapshot.
package metrics
import (
"context"
"database/sql"
"encoding/json"
"os"
"runtime"
"time"
"switchboard-core/database"
"switchboard-core/store"
)
// Snapshot is the top-level JSON response from GET /api/v1/admin/metrics.
type Snapshot struct {
NodeID string `json:"node_id"`
Runtime RuntimeMetrics `json:"runtime"`
DB DBMetrics `json:"db"`
Cluster *ClusterMetrics `json:"cluster,omitempty"`
Extensions ExtensionMetrics `json:"extensions"`
}
type RuntimeMetrics struct {
Goroutines int `json:"goroutines"`
HeapAlloc uint64 `json:"heap_alloc"`
HeapSys uint64 `json:"heap_sys"`
StackInUse uint64 `json:"stack_in_use"`
GCCycles uint32 `json:"gc_cycles"`
GCPauseNs uint64 `json:"gc_pause_ns"`
GCCPUPercent float64 `json:"gc_cpu_pct"`
UptimeSec float64 `json:"uptime_sec"`
WSClients int `json:"ws_clients"`
ExtensionsLoaded int `json:"extensions_loaded"`
OpenFDs int `json:"open_fds"`
}
type DBMetrics struct {
LatencyMs float64 `json:"latency_ms"`
PoolActive int `json:"pool_active"`
PoolIdle int `json:"pool_idle"`
PoolMax int `json:"pool_max"`
WaitCount int64 `json:"wait_count"`
WaitDuration float64 `json:"wait_duration_ms"`
// PG-only fields (zero/omitted on SQLite)
DeadTuples *int64 `json:"dead_tuples,omitempty"`
ActiveBackends *int `json:"active_backends,omitempty"`
}
type ClusterMetrics struct {
Size int `json:"size"`
Nodes []ClusterNode `json:"nodes"`
}
type ClusterNode struct {
NodeID string `json:"node_id"`
Endpoint string `json:"endpoint"`
UptimeSec float64 `json:"uptime_sec"`
HeartbeatAge int64 `json:"heartbeat_age_ms"`
Stats json.RawMessage `json:"stats"`
}
type ExtensionMetrics struct {
StarlarkExecTotal uint64 `json:"starlark_exec_total"`
StarlarkErrorsTotal uint64 `json:"starlark_errors_total"`
StarlarkAvgDuration float64 `json:"starlark_avg_duration_ms"`
TriggerFiresTotal int64 `json:"trigger_fires_total"`
EventBusPublished int64 `json:"event_bus_published"`
EventBusDelivered int64 `json:"event_bus_delivered"`
}
// ConnCounter provides WebSocket connection count (satisfied by events.Hub).
type ConnCounter interface {
ConnCount() int
}
// BusCounter provides publish/deliver counts (satisfied by events.Bus).
type BusCounter interface {
PublishCount() int64
DeliverCount() int64
}
// SandboxStatsFunc returns cumulative sandbox execution counters.
type SandboxStatsFunc func() (execCount, errorCount uint64, avgDurationMs float64)
// TriggerFireCountFunc returns cumulative trigger fire count.
type TriggerFireCountFunc func() int64
// Collector gathers metrics on demand for the admin endpoint.
type Collector struct {
nodeID string
db *sql.DB
hub ConnCounter
bus BusCounter
stores store.Stores
sandboxStats SandboxStatsFunc
triggerFireCount TriggerFireCountFunc
startTime time.Time
}
// NewCollector creates a metrics collector with all required dependencies.
func NewCollector(nodeID string, db *sql.DB, hub ConnCounter, bus BusCounter, stores store.Stores, sandboxFn SandboxStatsFunc, triggerFn TriggerFireCountFunc, startTime time.Time) *Collector {
return &Collector{
nodeID: nodeID,
db: db,
hub: hub,
bus: bus,
stores: stores,
sandboxStats: sandboxFn,
triggerFireCount: triggerFn,
startTime: startTime,
}
}
// Collect gathers all metrics synchronously and returns a snapshot.
func (c *Collector) Collect(ctx context.Context) *Snapshot {
snap := &Snapshot{NodeID: c.nodeID}
snap.Runtime = c.collectRuntime()
snap.DB = c.collectDB(ctx)
snap.Cluster = c.collectCluster(ctx)
snap.Extensions = c.collectExtensions()
return snap
}
func (c *Collector) collectRuntime() RuntimeMetrics {
var m runtime.MemStats
runtime.ReadMemStats(&m)
extCount := 0
if c.stores.Packages != nil {
if pkgs, err := c.stores.Packages.List(context.Background()); err == nil {
for _, p := range pkgs {
if p.Status == "active" {
extCount++
}
}
}
}
return RuntimeMetrics{
Goroutines: runtime.NumGoroutine(),
HeapAlloc: m.HeapAlloc,
HeapSys: m.HeapSys,
StackInUse: m.StackInuse,
GCCycles: m.NumGC,
GCPauseNs: m.PauseNs[(m.NumGC+255)%256],
GCCPUPercent: m.GCCPUFraction * 100,
UptimeSec: time.Since(c.startTime).Seconds(),
WSClients: c.hub.ConnCount(),
ExtensionsLoaded: extCount,
OpenFDs: countOpenFDs(),
}
}
func (c *Collector) collectDB(ctx context.Context) DBMetrics {
dm := DBMetrics{}
if c.db == nil {
return dm
}
// Pool stats
stats := c.db.Stats()
dm.PoolActive = stats.InUse
dm.PoolIdle = stats.Idle
dm.PoolMax = stats.MaxOpenConnections
dm.WaitCount = stats.WaitCount
dm.WaitDuration = float64(stats.WaitDuration.Milliseconds())
// Latency probe
probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
start := time.Now()
if err := c.db.PingContext(probeCtx); err == nil {
dm.LatencyMs = float64(time.Since(start).Microseconds()) / 1000.0
}
// PG-only stats
if database.IsPostgres() {
var deadTuples int64
if err := c.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(n_dead_tup), 0) FROM pg_stat_user_tables`).Scan(&deadTuples); err == nil {
dm.DeadTuples = &deadTuples
}
var activeBackends int
if err := c.db.QueryRowContext(ctx, `SELECT count(*) FROM pg_stat_activity WHERE datname = current_database()`).Scan(&activeBackends); err == nil {
dm.ActiveBackends = &activeBackends
}
}
return dm
}
func (c *Collector) collectCluster(ctx context.Context) *ClusterMetrics {
if c.stores.Cluster == nil {
return nil
}
nodes, err := c.stores.Cluster.ListNodes(ctx)
if err != nil {
return nil
}
if len(nodes) == 0 {
return nil
}
cm := &ClusterMetrics{
Size: len(nodes),
Nodes: make([]ClusterNode, len(nodes)),
}
now := time.Now()
for i, n := range nodes {
// Extract uptime from stats JSONB
var uptimeSec float64
var statsMap map[string]any
if json.Unmarshal(n.Stats, &statsMap) == nil {
if u, ok := statsMap["uptime_sec"].(float64); ok {
uptimeSec = u
}
}
cm.Nodes[i] = ClusterNode{
NodeID: n.NodeID,
Endpoint: n.Endpoint,
UptimeSec: uptimeSec,
HeartbeatAge: now.Sub(n.Heartbeat).Milliseconds(),
Stats: n.Stats,
}
}
return cm
}
func (c *Collector) collectExtensions() ExtensionMetrics {
em := ExtensionMetrics{}
if c.sandboxStats != nil {
em.StarlarkExecTotal, em.StarlarkErrorsTotal, em.StarlarkAvgDuration = c.sandboxStats()
}
if c.triggerFireCount != nil {
em.TriggerFiresTotal = c.triggerFireCount()
}
if c.bus != nil {
em.EventBusPublished = c.bus.PublishCount()
em.EventBusDelivered = c.bus.DeliverCount()
}
return em
}
// countOpenFDs counts open file descriptors via /proc/self/fd (Linux only).
func countOpenFDs() int {
entries, err := os.ReadDir("/proc/self/fd")
if err != nil {
return -1
}
return len(entries)
}