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 349ff5c80a
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m46s
CI/CD / test-sqlite (pull_request) Successful in 2m53s
CI/CD / build-and-deploy (pull_request) Successful in 1m42s
Fix health tab: node identity indicator + cluster card overflow
Add node_id to metrics snapshot so the health tab shows which instance
the user is viewing from. Hoist nodeID computation so it works on both
SQLite and Postgres deployments. Fix cluster node card overflow with
text-overflow ellipsis and proper flex constraints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:40:18 +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)
}