Feat v0.6.4 health metrics (#39)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #39.
This commit is contained in:
@@ -34,6 +34,26 @@ type Registry struct {
|
||||
startTime time.Time
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
// Optional callbacks — set after construction via setters.
|
||||
sandboxStats func() (exec, errors uint64, avgMs float64)
|
||||
triggerFires func() int64
|
||||
extensionCount func() int
|
||||
}
|
||||
|
||||
// SetSandboxStats registers a callback for sandbox execution counters.
|
||||
func (r *Registry) SetSandboxStats(fn func() (uint64, uint64, float64)) {
|
||||
r.sandboxStats = fn
|
||||
}
|
||||
|
||||
// SetTriggerFireCount registers a callback for trigger fire count.
|
||||
func (r *Registry) SetTriggerFireCount(fn func() int64) {
|
||||
r.triggerFires = fn
|
||||
}
|
||||
|
||||
// SetExtensionCount registers a callback for active extension count.
|
||||
func (r *Registry) SetExtensionCount(fn func() int) {
|
||||
r.extensionCount = fn
|
||||
}
|
||||
|
||||
// NewRegistry creates a cluster registry instance.
|
||||
@@ -129,13 +149,28 @@ func (r *Registry) collectStats() json.RawMessage {
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
stats := map[string]any{
|
||||
"goroutines": runtime.NumGoroutine(),
|
||||
"heap_alloc": m.HeapAlloc,
|
||||
"heap_sys": m.HeapSys,
|
||||
"gc_cycles": m.NumGC,
|
||||
"gc_pause_ns": m.PauseNs[(m.NumGC+255)%256],
|
||||
"uptime_sec": time.Since(r.startTime).Seconds(),
|
||||
"ws_clients": r.hub.ConnCount(),
|
||||
"goroutines": runtime.NumGoroutine(),
|
||||
"heap_alloc": m.HeapAlloc,
|
||||
"heap_sys": m.HeapSys,
|
||||
"stack_in_use": m.StackInuse,
|
||||
"gc_cycles": m.NumGC,
|
||||
"gc_pause_ns": m.PauseNs[(m.NumGC+255)%256],
|
||||
"gc_cpu_pct": m.GCCPUFraction * 100,
|
||||
"uptime_sec": time.Since(r.startTime).Seconds(),
|
||||
"ws_clients": r.hub.ConnCount(),
|
||||
}
|
||||
|
||||
if r.extensionCount != nil {
|
||||
stats["extensions_loaded"] = r.extensionCount()
|
||||
}
|
||||
if r.sandboxStats != nil {
|
||||
exec, errors, avgMs := r.sandboxStats()
|
||||
stats["starlark_exec_total"] = exec
|
||||
stats["starlark_errors_total"] = errors
|
||||
stats["starlark_avg_duration_ms"] = avgMs
|
||||
}
|
||||
if r.triggerFires != nil {
|
||||
stats["trigger_fires_total"] = r.triggerFires()
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(stats)
|
||||
|
||||
@@ -75,6 +75,46 @@ func TestCollectStats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectStatsFattened(t *testing.T) {
|
||||
hub := &mockHub{count: 3}
|
||||
reg := NewRegistry("test-node", "", nil, hub, RegistryConfig{
|
||||
HeartbeatInterval: 10 * time.Second,
|
||||
StaleThreshold: 30 * time.Second,
|
||||
})
|
||||
reg.SetSandboxStats(func() (uint64, uint64, float64) { return 100, 5, 12.3 })
|
||||
reg.SetTriggerFireCount(func() int64 { return 42 })
|
||||
reg.SetExtensionCount(func() int { return 8 })
|
||||
|
||||
data := reg.collectStats()
|
||||
|
||||
var stats map[string]any
|
||||
if err := json.Unmarshal(data, &stats); err != nil {
|
||||
t.Fatalf("collectStats returned invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
// New fattened keys
|
||||
fatKeys := []string{
|
||||
"stack_in_use", "gc_cpu_pct",
|
||||
"extensions_loaded", "starlark_exec_total", "starlark_errors_total",
|
||||
"starlark_avg_duration_ms", "trigger_fires_total",
|
||||
}
|
||||
for _, key := range fatKeys {
|
||||
if _, ok := stats[key]; !ok {
|
||||
t.Errorf("missing fattened stats key: %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
if v := stats["extensions_loaded"].(float64); int(v) != 8 {
|
||||
t.Errorf("extensions_loaded = %v, want 8", v)
|
||||
}
|
||||
if v := stats["starlark_exec_total"].(float64); int(v) != 100 {
|
||||
t.Errorf("starlark_exec_total = %v, want 100", v)
|
||||
}
|
||||
if v := stats["trigger_fires_total"].(float64); int(v) != 42 {
|
||||
t.Errorf("trigger_fires_total = %v, want 42", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryStartStop(t *testing.T) {
|
||||
ms := &mockClusterStore{heartbeatRows: 1}
|
||||
hub := &mockHub{count: 0}
|
||||
|
||||
@@ -3,6 +3,7 @@ package events
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -14,6 +15,8 @@ type Bus struct {
|
||||
subs map[string][]*subscription
|
||||
seq uint64 // subscription ID counter
|
||||
broadcastHook func(Event) // called after Publish for cross-pod fan-out; nil-safe
|
||||
publishCount atomic.Int64
|
||||
deliverCount atomic.Int64
|
||||
}
|
||||
|
||||
type subscription struct {
|
||||
@@ -86,6 +89,8 @@ func (b *Bus) Publish(event Event) {
|
||||
// the broadcastHook. Used by the Postgres listener to re-publish remote
|
||||
// events without causing an infinite re-broadcast loop.
|
||||
func (b *Bus) publishLocal(event Event) {
|
||||
b.publishCount.Add(1)
|
||||
|
||||
b.mu.RLock()
|
||||
var matched []Handler
|
||||
for pattern, subs := range b.subs {
|
||||
@@ -98,6 +103,7 @@ func (b *Bus) publishLocal(event Event) {
|
||||
b.mu.RUnlock()
|
||||
|
||||
for _, h := range matched {
|
||||
b.deliverCount.Add(1)
|
||||
h(event)
|
||||
}
|
||||
}
|
||||
@@ -126,6 +132,12 @@ func (b *Bus) PublishAsync(event Event) {
|
||||
}
|
||||
}
|
||||
|
||||
// PublishCount returns the cumulative number of events published.
|
||||
func (b *Bus) PublishCount() int64 { return b.publishCount.Load() }
|
||||
|
||||
// DeliverCount returns the cumulative number of subscriber deliveries.
|
||||
func (b *Bus) DeliverCount() int64 { return b.deliverCount.Load() }
|
||||
|
||||
// match checks if a concrete label matches a subscription pattern.
|
||||
//
|
||||
// "chat.message.abc" matches "chat.message.abc" (exact)
|
||||
|
||||
24
server/handlers/admin_metrics.go
Normal file
24
server/handlers/admin_metrics.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"switchboard-core/metrics"
|
||||
)
|
||||
|
||||
// MetricsHandler serves the admin metrics endpoint.
|
||||
type MetricsHandler struct {
|
||||
collector *metrics.Collector
|
||||
}
|
||||
|
||||
// NewMetricsHandler creates a MetricsHandler.
|
||||
func NewMetricsHandler(c *metrics.Collector) *MetricsHandler {
|
||||
return &MetricsHandler{collector: c}
|
||||
}
|
||||
|
||||
// GetMetrics returns a full metrics snapshot.
|
||||
// GET /api/v1/admin/metrics
|
||||
func (h *MetricsHandler) GetMetrics(c *gin.Context) {
|
||||
snap := h.collector.Collect(c.Request.Context())
|
||||
c.JSON(200, snap)
|
||||
}
|
||||
187
server/handlers/admin_metrics_test.go
Normal file
187
server/handlers/admin_metrics_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"switchboard-core/metrics"
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
// mockHub implements metrics.ConnCounter for tests.
|
||||
type mockHub struct{ count int }
|
||||
|
||||
func (m *mockHub) ConnCount() int { return m.count }
|
||||
|
||||
// mockBus implements metrics.BusCounter for tests.
|
||||
type mockBus struct{ pub, del int64 }
|
||||
|
||||
func (m *mockBus) PublishCount() int64 { return m.pub }
|
||||
func (m *mockBus) DeliverCount() int64 { return m.del }
|
||||
|
||||
func TestMetrics_SQLiteShape(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
collector := metrics.NewCollector(
|
||||
"test-node",
|
||||
nil, // no DB
|
||||
&mockHub{count: 5},
|
||||
&mockBus{pub: 10, del: 20},
|
||||
store.Stores{}, // no cluster store
|
||||
func() (uint64, uint64, float64) { return 42, 3, 12.5 },
|
||||
func() int64 { return 7 },
|
||||
time.Now().Add(-10*time.Minute),
|
||||
)
|
||||
|
||||
h := NewMetricsHandler(collector)
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/admin/metrics", h.GetMetrics)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var snap metrics.Snapshot
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &snap); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// Node ID
|
||||
if snap.NodeID != "test-node" {
|
||||
t.Errorf("node_id = %q, want %q", snap.NodeID, "test-node")
|
||||
}
|
||||
|
||||
// Runtime
|
||||
if snap.Runtime.WSClients != 5 {
|
||||
t.Errorf("ws_clients = %d, want 5", snap.Runtime.WSClients)
|
||||
}
|
||||
if snap.Runtime.UptimeSec < 600 {
|
||||
t.Errorf("uptime_sec = %f, want >= 600", snap.Runtime.UptimeSec)
|
||||
}
|
||||
|
||||
// Cluster should be nil (SQLite)
|
||||
if snap.Cluster != nil {
|
||||
t.Errorf("cluster should be nil for SQLite, got %+v", snap.Cluster)
|
||||
}
|
||||
|
||||
// Extensions
|
||||
if snap.Extensions.StarlarkExecTotal != 42 {
|
||||
t.Errorf("starlark_exec_total = %d, want 42", snap.Extensions.StarlarkExecTotal)
|
||||
}
|
||||
if snap.Extensions.StarlarkErrorsTotal != 3 {
|
||||
t.Errorf("starlark_errors_total = %d, want 3", snap.Extensions.StarlarkErrorsTotal)
|
||||
}
|
||||
if snap.Extensions.StarlarkAvgDuration != 12.5 {
|
||||
t.Errorf("starlark_avg_duration_ms = %f, want 12.5", snap.Extensions.StarlarkAvgDuration)
|
||||
}
|
||||
if snap.Extensions.TriggerFiresTotal != 7 {
|
||||
t.Errorf("trigger_fires_total = %d, want 7", snap.Extensions.TriggerFiresTotal)
|
||||
}
|
||||
if snap.Extensions.EventBusPublished != 10 {
|
||||
t.Errorf("event_bus_published = %d, want 10", snap.Extensions.EventBusPublished)
|
||||
}
|
||||
if snap.Extensions.EventBusDelivered != 20 {
|
||||
t.Errorf("event_bus_delivered = %d, want 20", snap.Extensions.EventBusDelivered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetrics_WithCluster(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
mock := &mockClusterStore{
|
||||
nodes: []store.ClusterNode{
|
||||
{
|
||||
NodeID: "node-1",
|
||||
Endpoint: "http://node-1:8080",
|
||||
Heartbeat: time.Now(),
|
||||
Stats: json.RawMessage(`{"uptime_sec":120,"ws_clients":3}`),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
collector := metrics.NewCollector(
|
||||
"test-node",
|
||||
nil,
|
||||
&mockHub{count: 3},
|
||||
&mockBus{},
|
||||
store.Stores{Cluster: mock},
|
||||
func() (uint64, uint64, float64) { return 0, 0, 0 },
|
||||
func() int64 { return 0 },
|
||||
time.Now(),
|
||||
)
|
||||
|
||||
h := NewMetricsHandler(collector)
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/admin/metrics", h.GetMetrics)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var snap metrics.Snapshot
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &snap); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if snap.Cluster == nil {
|
||||
t.Fatal("cluster should not be nil with cluster store")
|
||||
}
|
||||
if snap.Cluster.Size != 1 {
|
||||
t.Errorf("cluster.size = %d, want 1", snap.Cluster.Size)
|
||||
}
|
||||
if snap.Cluster.Nodes[0].NodeID != "node-1" {
|
||||
t.Errorf("node_id = %q, want %q", snap.Cluster.Nodes[0].NodeID, "node-1")
|
||||
}
|
||||
if snap.Cluster.Nodes[0].UptimeSec != 120 {
|
||||
t.Errorf("uptime_sec = %f, want 120", snap.Cluster.Nodes[0].UptimeSec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetrics_ExtensionCounters(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
collector := metrics.NewCollector(
|
||||
"test-node",
|
||||
nil,
|
||||
&mockHub{},
|
||||
&mockBus{pub: 100, del: 500},
|
||||
store.Stores{},
|
||||
func() (uint64, uint64, float64) { return 1000, 50, 8.3 },
|
||||
func() int64 { return 25 },
|
||||
time.Now(),
|
||||
)
|
||||
|
||||
h := NewMetricsHandler(collector)
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/admin/metrics", h.GetMetrics)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
var snap metrics.Snapshot
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &snap)
|
||||
|
||||
if snap.Extensions.StarlarkExecTotal != 1000 {
|
||||
t.Errorf("exec total = %d, want 1000", snap.Extensions.StarlarkExecTotal)
|
||||
}
|
||||
if snap.Extensions.EventBusPublished != 100 {
|
||||
t.Errorf("bus published = %d, want 100", snap.Extensions.EventBusPublished)
|
||||
}
|
||||
if snap.Extensions.EventBusDelivered != 500 {
|
||||
t.Errorf("bus delivered = %d, want 500", snap.Extensions.EventBusDelivered)
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ func main() {
|
||||
}
|
||||
|
||||
// ── Server startup ──────────────────────
|
||||
startTime := time.Now()
|
||||
cfg := config.Load()
|
||||
|
||||
// Structured logging — must be first so all subsequent
|
||||
@@ -200,19 +201,40 @@ func main() {
|
||||
// ── WebSocket Hub ─────────────────────────
|
||||
hub := events.NewHub(bus, middleware.GetAllowedOrigins(cfg))
|
||||
|
||||
// ── Node Identity ────────────────
|
||||
// Used by cluster registry (PG) and metrics endpoint (all deployments).
|
||||
nodeID := cfg.ClusterNodeID
|
||||
if nodeID == "" {
|
||||
hostname, _ := os.Hostname()
|
||||
nodeID = fmt.Sprintf("%s-%d", hostname, os.Getpid())
|
||||
}
|
||||
|
||||
// ── Cluster Registry ────────────
|
||||
// PG-backed node self-registration + heartbeat. No-op on SQLite.
|
||||
var clusterReg *cluster.Registry
|
||||
if database.IsPostgres() && stores.Cluster != nil {
|
||||
nodeID := cfg.ClusterNodeID
|
||||
if nodeID == "" {
|
||||
hostname, _ := os.Hostname()
|
||||
nodeID = fmt.Sprintf("%s-%d", hostname, os.Getpid())
|
||||
}
|
||||
clusterReg = cluster.NewRegistry(nodeID, cfg.ClusterEndpoint, stores.Cluster, hub, cluster.RegistryConfig{
|
||||
HeartbeatInterval: cfg.ClusterHeartbeatInterval,
|
||||
StaleThreshold: cfg.ClusterStaleThreshold,
|
||||
})
|
||||
clusterReg.SetSandboxStats(sandbox.SandboxStats)
|
||||
clusterReg.SetTriggerFireCount(triggerEngine.FireCount)
|
||||
clusterReg.SetExtensionCount(func() int {
|
||||
if stores.Packages == nil {
|
||||
return 0
|
||||
}
|
||||
pkgs, err := stores.Packages.List(context.Background())
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for _, p := range pkgs {
|
||||
if p.Status == "active" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
})
|
||||
if err := clusterReg.Start(); err != nil {
|
||||
log.Printf("⚠ Cluster registry failed to start: %v", err)
|
||||
clusterReg = nil
|
||||
@@ -255,7 +277,7 @@ func main() {
|
||||
}
|
||||
|
||||
// Health check (k8s probes hit this directly)
|
||||
base.GET("/health", func(c *gin.Context) {
|
||||
buildHealthResponse := func() gin.H {
|
||||
info := gin.H{
|
||||
"status": "ok",
|
||||
"version": Version,
|
||||
@@ -263,8 +285,15 @@ func main() {
|
||||
"database_name": database.Name(),
|
||||
"schema_version": database.SchemaVersion(),
|
||||
}
|
||||
if database.IsConnected() {
|
||||
info["registration_enabled"] = handlers.IsRegistrationEnabled(stores)
|
||||
}
|
||||
appendClusterHealth(info, clusterReg, stores)
|
||||
c.JSON(200, info)
|
||||
return info
|
||||
}
|
||||
|
||||
base.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, buildHealthResponse())
|
||||
})
|
||||
|
||||
// Liveness: process is alive and serving (no dependency checks).
|
||||
@@ -352,20 +381,9 @@ func main() {
|
||||
|
||||
api := base.Group("/api/v1")
|
||||
{
|
||||
// Health (routable through ingress)
|
||||
// Health (routable through ingress — same shape as /health)
|
||||
api.GET("/health", func(c *gin.Context) {
|
||||
info := gin.H{
|
||||
"status": "ok",
|
||||
"version": Version,
|
||||
"schema_version": database.SchemaVersion(),
|
||||
"database": database.IsConnected(),
|
||||
"database_name": database.Name(),
|
||||
}
|
||||
if database.IsConnected() {
|
||||
info["registration_enabled"] = handlers.IsRegistrationEnabled(stores)
|
||||
}
|
||||
appendClusterHealth(info, clusterReg, stores)
|
||||
c.JSON(200, info)
|
||||
c.JSON(200, buildHealthResponse())
|
||||
})
|
||||
|
||||
authGroup := api.Group("/auth")
|
||||
@@ -794,6 +812,16 @@ func main() {
|
||||
admin.GET("/cluster", clusterH.ListNodes)
|
||||
}
|
||||
|
||||
// ── Metrics ─────────────────
|
||||
metricsCollector := metrics.NewCollector(
|
||||
nodeID, database.DB, hub, bus, stores,
|
||||
sandbox.SandboxStats,
|
||||
triggerEngine.FireCount,
|
||||
startTime,
|
||||
)
|
||||
metricsH := handlers.NewMetricsHandler(metricsCollector)
|
||||
admin.GET("/metrics", metricsH.GetMetrics)
|
||||
|
||||
// ── Backup/Restore ─────────
|
||||
backupH := handlers.NewBackupHandler(stores, packagesDir, cfg.StoragePath)
|
||||
admin.POST("/backup", backupH.CreateBackup)
|
||||
|
||||
256
server/metrics/collector.go
Normal file
256
server/metrics/collector.go
Normal file
@@ -0,0 +1,256 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -23,16 +23,37 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
|
||||
starlarkjson "go.starlark.net/lib/json"
|
||||
|
||||
"switchboard-core/events"
|
||||
"switchboard-core/metrics"
|
||||
"switchboard-core/models"
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
// sandboxStats tracks cumulative execution counters for the admin metrics endpoint.
|
||||
var sandboxStats struct {
|
||||
execCount atomic.Int64
|
||||
errorCount atomic.Int64
|
||||
totalDurationNs atomic.Int64
|
||||
}
|
||||
|
||||
// SandboxStats returns cumulative execution counters.
|
||||
func SandboxStats() (execCount, errorCount uint64, avgDurationMs float64) {
|
||||
exec := sandboxStats.execCount.Load()
|
||||
errs := sandboxStats.errorCount.Load()
|
||||
totalNs := sandboxStats.totalDurationNs.Load()
|
||||
if exec > 0 {
|
||||
avgDurationMs = float64(totalNs) / float64(exec) / 1e6
|
||||
}
|
||||
return uint64(exec), uint64(errs), avgDurationMs
|
||||
}
|
||||
|
||||
// RunContext carries per-invocation state that modules need but which
|
||||
// varies per caller (API route vs filter vs task). Nil is safe — modules
|
||||
// that need RunContext fields gracefully degrade.
|
||||
@@ -142,7 +163,20 @@ func (r *Runner) ExecPackage(ctx context.Context, pkg *store.PackageRegistration
|
||||
|
||||
log.Printf(" 🔧 runner: exec %s (%d modules granted)", pkg.ID, len(modules))
|
||||
|
||||
return r.sandbox.ExecWithLoader(ctx, pkg.ID+"/script.star", script, modules, loader)
|
||||
start := time.Now()
|
||||
result, err := r.sandbox.ExecWithLoader(ctx, pkg.ID+"/script.star", script, modules, loader)
|
||||
duration := time.Since(start)
|
||||
|
||||
sandboxStats.execCount.Add(1)
|
||||
sandboxStats.totalDurationNs.Add(int64(duration))
|
||||
status := "success"
|
||||
if err != nil {
|
||||
sandboxStats.errorCount.Add(1)
|
||||
status = "error"
|
||||
}
|
||||
metrics.SandboxExecutionsTotal.WithLabelValues("exec", status).Inc()
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
// loadScript reads the entry point script from disk (primary path)
|
||||
@@ -266,8 +300,20 @@ func (r *Runner) CallEntryPoint(ctx context.Context, pkg *store.PackageRegistrat
|
||||
return nil, result.Output, fmt.Errorf("package %q: %s is not callable", pkg.ID, entryPoint)
|
||||
}
|
||||
|
||||
val, callOutput, err := r.sandbox.Call(ctx, callable, args, kwargs)
|
||||
return val, result.Output + callOutput, err
|
||||
start := time.Now()
|
||||
val, callOutput, callErr := r.sandbox.Call(ctx, callable, args, kwargs)
|
||||
duration := time.Since(start)
|
||||
|
||||
sandboxStats.execCount.Add(1)
|
||||
sandboxStats.totalDurationNs.Add(int64(duration))
|
||||
status := "success"
|
||||
if callErr != nil {
|
||||
sandboxStats.errorCount.Add(1)
|
||||
status = "error"
|
||||
}
|
||||
metrics.SandboxExecutionsTotal.WithLabelValues(entryPoint, status).Inc()
|
||||
|
||||
return val, result.Output + callOutput, callErr
|
||||
}
|
||||
|
||||
// buildModules assembles the module map based on granted permissions.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
@@ -30,10 +31,15 @@ type Engine struct {
|
||||
unsubs map[string]func() // trigger_id → bus unsubscribe
|
||||
cronIDs map[string]cron.EntryID // scheduled_task_id → cron entry
|
||||
|
||||
fireCount atomic.Int64 // cumulative trigger fires
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// FireCount returns the cumulative number of trigger fires.
|
||||
func (e *Engine) FireCount() int64 { return e.fireCount.Load() }
|
||||
|
||||
// New creates a trigger engine. Call Start() to begin processing.
|
||||
func New(stores store.Stores, runner *sandbox.Runner, bus *events.Bus) *Engine {
|
||||
return &Engine{
|
||||
@@ -201,8 +207,9 @@ func (e *Engine) logExecution(triggerID, scheduledTaskID string, firedAt time.Ti
|
||||
}
|
||||
}
|
||||
|
||||
// publishEvent emits a trigger lifecycle event on the bus.
|
||||
// publishEvent emits a trigger lifecycle event on the bus and increments the fire counter.
|
||||
func (e *Engine) publishEvent(label string, triggerID string) {
|
||||
e.fireCount.Add(1)
|
||||
if e.bus != nil {
|
||||
payload, _ := json.Marshal(map[string]any{"trigger_id": triggerID})
|
||||
e.bus.Publish(events.Event{
|
||||
|
||||
Reference in New Issue
Block a user