Feat v0.6.4 health metrics (#39)
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

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:
2026-03-31 14:05:49 +00:00
committed by xcaliber
parent 3d4228f868
commit 36d6158940
22 changed files with 937 additions and 286 deletions

View File

@@ -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)

View File

@@ -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}

View File

@@ -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)

View 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)
}

View 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)
}
}

View File

@@ -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
View 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)
}

View File

@@ -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.

View File

@@ -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{