Add comprehensive trace logging and observability features: - Enhanced trace store with owner-scoped queries, filtering (agent, action, time range), pagination, streaming export, and retention cleanup - REST API endpoints: GET /api/traces (list with filters), GET /api/traces/export (streaming JSON/CSV), GET /api/traces/stats (action counts) - Owner isolation enforced at every layer (store, API, tests) - Hand-rolled Prometheus metrics (pure Go, zero CGO): traces_total, traces_by_action, errors_total, active_agents at GET /metrics - Configurable slog JSON handler with --log-level flag - Request ID middleware for cross-referencing logs and traces - Batch trace writing (64 entries or 100ms flush interval) - Background retention cleanup via --trace-retention flag - SQL migration 002 adds owner_id column and composite indexes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
123 lines
2.5 KiB
Go
123 lines
2.5 KiB
Go
package trace
|
|
|
|
import (
|
|
"bytes"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestMetrics_IncTrace(t *testing.T) {
|
|
m := NewMetrics()
|
|
|
|
m.IncTrace("send_message")
|
|
m.IncTrace("send_message")
|
|
m.IncTrace("read_inbox")
|
|
|
|
if got := m.tracesTotal.Load(); got != 3 {
|
|
t.Errorf("tracesTotal = %d, want 3", got)
|
|
}
|
|
|
|
m.mu.RLock()
|
|
if got := m.tracesByAction["send_message"].Load(); got != 2 {
|
|
t.Errorf("tracesByAction[send_message] = %d, want 2", got)
|
|
}
|
|
if got := m.tracesByAction["read_inbox"].Load(); got != 1 {
|
|
t.Errorf("tracesByAction[read_inbox] = %d, want 1", got)
|
|
}
|
|
m.mu.RUnlock()
|
|
}
|
|
|
|
func TestMetrics_IncError(t *testing.T) {
|
|
m := NewMetrics()
|
|
|
|
m.IncError()
|
|
m.IncError()
|
|
|
|
if got := m.errorsTotal.Load(); got != 2 {
|
|
t.Errorf("errorsTotal = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestMetrics_SetActiveAgents(t *testing.T) {
|
|
m := NewMetrics()
|
|
|
|
m.SetActiveAgents(5)
|
|
if got := m.activeAgents.Load(); got != 5 {
|
|
t.Errorf("activeAgents = %d, want 5", got)
|
|
}
|
|
|
|
m.SetActiveAgents(3)
|
|
if got := m.activeAgents.Load(); got != 3 {
|
|
t.Errorf("activeAgents = %d, want 3", got)
|
|
}
|
|
}
|
|
|
|
func TestMetrics_WritePrometheus(t *testing.T) {
|
|
m := NewMetrics()
|
|
|
|
m.IncTrace("send_message")
|
|
m.IncTrace("send_message")
|
|
m.IncTrace("read_inbox")
|
|
m.IncError()
|
|
m.SetActiveAgents(7)
|
|
|
|
var buf bytes.Buffer
|
|
m.WritePrometheus(&buf)
|
|
output := buf.String()
|
|
|
|
expected := []string{
|
|
"# HELP synapbus_traces_total",
|
|
"# TYPE synapbus_traces_total counter",
|
|
"synapbus_traces_total 3",
|
|
"# HELP synapbus_traces_by_action",
|
|
"# TYPE synapbus_traces_by_action counter",
|
|
`synapbus_traces_by_action{action="read_inbox"} 1`,
|
|
`synapbus_traces_by_action{action="send_message"} 2`,
|
|
"# HELP synapbus_errors_total",
|
|
"# TYPE synapbus_errors_total counter",
|
|
"synapbus_errors_total 1",
|
|
"# HELP synapbus_active_agents",
|
|
"# TYPE synapbus_active_agents gauge",
|
|
"synapbus_active_agents 7",
|
|
}
|
|
|
|
for _, exp := range expected {
|
|
if !strings.Contains(output, exp) {
|
|
t.Errorf("missing expected output: %q", exp)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNullMetrics_DoesNotPanic(t *testing.T) {
|
|
var m NullMetrics
|
|
// These should not panic
|
|
m.IncTrace("action")
|
|
m.IncError()
|
|
m.SetActiveAgents(5)
|
|
}
|
|
|
|
func TestMetrics_ConcurrentAccess(t *testing.T) {
|
|
m := NewMetrics()
|
|
|
|
// Concurrent writes should not race
|
|
done := make(chan struct{})
|
|
for i := 0; i < 10; i++ {
|
|
go func() {
|
|
for j := 0; j < 100; j++ {
|
|
m.IncTrace("action")
|
|
m.IncError()
|
|
m.SetActiveAgents(j)
|
|
}
|
|
done <- struct{}{}
|
|
}()
|
|
}
|
|
|
|
for i := 0; i < 10; i++ {
|
|
<-done
|
|
}
|
|
|
|
if got := m.tracesTotal.Load(); got != 1000 {
|
|
t.Errorf("tracesTotal = %d, want 1000", got)
|
|
}
|
|
}
|