Files
agents/control-plane/internal/services/observability_forwarder_integration_test.go
zeekayandGitHub 5b5cf9e9db feat: merge AgentField control plane + SDKs as Hanzo Agents (#1)
Transforms hanzoai/agents from a marketplace config repo into a full
agent orchestration platform by merging the AgentField infrastructure:

- Go control plane (K8s-like agent orchestration, service mesh, DAG workflows)
- Python SDK (FastAPI-based agent framework, LiteLLM, memory, DID/VC)
- TypeScript SDK (@hanzo/agents - Vercel AI SDK, MCP, workflows)
- Go SDK (idiomatic agent builder + client)
- React 19 embedded dashboard (XyFlow DAGs, Recharts, Radix UI)
- Docker/K8s/Helm deployment configs
- Examples: hello world, deep research, RAG, simulation, benchmarks
- Functional test suite across Python/Go/TypeScript

All references rebranded: AgentField → Hanzo Agents
- Module: github.com/hanzoai/agents/control-plane
- Python: hanzo_agents
- TypeScript: @hanzo/agents
- Go: github.com/hanzoai/agents/sdk/go
- Env vars: HANZO_AGENTS_*
- Config: hanzo-agents.yaml

Previous marketplace configs moved to config/ directory.
2026-02-09 18:53:09 -08:00

517 lines
14 KiB
Go

//go:build integration
// +build integration
package services
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/hanzoai/agents/control-plane/internal/events"
"github.com/hanzoai/agents/control-plane/internal/storage"
"github.com/hanzoai/agents/control-plane/pkg/types"
"github.com/stretchr/testify/require"
)
// Integration tests for the observability forwarder with real storage.
// Run with: go test -tags=integration ./internal/services/...
// setupIntegrationTest creates a real storage instance for integration testing.
func setupIntegrationTest(t *testing.T) (*storage.LocalStorage, context.Context) {
t.Helper()
ctx := context.Background()
tempDir := t.TempDir()
cfg := storage.StorageConfig{
Mode: "local",
Local: storage.LocalStorageConfig{
DatabasePath: filepath.Join(tempDir, "hanzo-agents.db"),
KVStorePath: filepath.Join(tempDir, "hanzo-agents.bolt"),
},
}
ls := storage.NewLocalStorage(storage.LocalStorageConfig{})
if err := ls.Initialize(ctx, cfg); err != nil {
if strings.Contains(err.Error(), "no such module: fts5") {
t.Skip("sqlite3 compiled without FTS5; skipping integration test")
}
t.Fatalf("initialize storage: %v", err)
}
t.Cleanup(func() {
_ = ls.Close(ctx)
})
return ls, ctx
}
// TestIntegration_EndToEndWebhookDelivery tests the complete flow from event publishing
// through storage, forwarder, and HTTP delivery.
func TestIntegration_EndToEndWebhookDelivery(t *testing.T) {
store, ctx := setupIntegrationTest(t)
// Track received events
var (
mu sync.Mutex
receivedEvents []types.ObservabilityEventBatch
)
// Start mock webhook server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Logf("error reading body: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
var batch types.ObservabilityEventBatch
if err := json.Unmarshal(body, &batch); err != nil {
t.Logf("error unmarshaling: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
mu.Lock()
receivedEvents = append(receivedEvents, batch)
mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Configure webhook in storage
secret := "integration-test-secret"
config := &types.ObservabilityWebhookConfig{
ID: "global",
URL: server.URL,
Secret: &secret,
Headers: map[string]string{
"X-Integration-Test": "true",
},
Enabled: true,
}
err := store.SetObservabilityWebhook(ctx, config)
require.NoError(t, err)
// Create and start forwarder
cfg := ObservabilityForwarderConfig{
BatchSize: 2,
BatchTimeout: 100 * time.Millisecond,
WorkerCount: 1,
MaxAttempts: 2,
}
forwarder := NewObservabilityForwarder(store, cfg)
err = forwarder.Start(ctx)
require.NoError(t, err)
defer forwarder.Stop(ctx)
// Publish various event types
events.PublishExecutionCreated("exec-int-1", "wf-1", "agent-1", nil)
events.PublishExecutionStarted("exec-int-1", "wf-1", "agent-1", nil)
events.PublishNodeOnline("node-int-1", map[string]interface{}{"ip": "10.0.0.1"})
events.PublishExecutionCompleted("exec-int-1", "wf-1", "agent-1", map[string]interface{}{"result": "success"})
// Wait for batches to be sent
time.Sleep(500 * time.Millisecond)
// Verify events were received
mu.Lock()
defer mu.Unlock()
require.Greater(t, len(receivedEvents), 0, "should have received at least one batch")
// Count total events
totalEvents := 0
for _, batch := range receivedEvents {
totalEvents += batch.EventCount
}
require.GreaterOrEqual(t, totalEvents, 4, "should have received at least 4 events")
// Verify forwarder status
status := forwarder.GetStatus()
require.True(t, status.Enabled)
require.GreaterOrEqual(t, status.EventsForwarded, int64(4))
require.Equal(t, int64(0), status.EventsDropped)
}
// TestIntegration_DeadLetterQueueFlow tests the complete DLQ flow including
// failed delivery, storage in DLQ, and successful redrive.
func TestIntegration_DeadLetterQueueFlow(t *testing.T) {
store, ctx := setupIntegrationTest(t)
failCount := int32(0)
shouldFail := atomic.Bool{}
shouldFail.Store(true)
// Start mock webhook server that can be toggled to fail/succeed
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&failCount, 1)
if shouldFail.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Configure webhook
config := &types.ObservabilityWebhookConfig{
ID: "global",
URL: server.URL,
Enabled: true,
}
err := store.SetObservabilityWebhook(ctx, config)
require.NoError(t, err)
// Create forwarder with quick retries
cfg := ObservabilityForwarderConfig{
BatchSize: 1,
BatchTimeout: 50 * time.Millisecond,
WorkerCount: 1,
MaxAttempts: 2,
RetryBackoff: 10 * time.Millisecond,
MaxRetryBackoff: 50 * time.Millisecond,
}
forwarder := NewObservabilityForwarder(store, cfg)
err = forwarder.Start(ctx)
require.NoError(t, err)
defer forwarder.Stop(ctx)
// Publish events that will fail
events.PublishExecutionFailed("exec-dlq-1", "wf-1", "agent-1", map[string]interface{}{"error": "test"})
// Wait for retries and DLQ insertion
time.Sleep(500 * time.Millisecond)
// Verify events are in DLQ
dlqCount, err := store.GetDeadLetterQueueCount(ctx)
require.NoError(t, err)
require.Greater(t, dlqCount, int64(0), "events should be in DLQ")
// Verify forwarder shows dropped events
status := forwarder.GetStatus()
require.Greater(t, status.EventsDropped, int64(0))
require.Greater(t, status.DeadLetterCount, int64(0))
// Now make the server succeed
shouldFail.Store(false)
// Redrive the events
response := forwarder.Redrive(ctx)
require.True(t, response.Success, "redrive should succeed: %s", response.Message)
require.Greater(t, response.Processed, 0)
// Verify DLQ is empty
dlqCount, err = store.GetDeadLetterQueueCount(ctx)
require.NoError(t, err)
require.Equal(t, int64(0), dlqCount, "DLQ should be empty after successful redrive")
}
// TestIntegration_ConfigReload tests that the forwarder correctly reloads
// configuration when webhook settings change.
func TestIntegration_ConfigReload(t *testing.T) {
store, ctx := setupIntegrationTest(t)
var receivedCount int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&receivedCount, 1)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Start with webhook disabled (no config)
cfg := ObservabilityForwarderConfig{
BatchSize: 1,
BatchTimeout: 50 * time.Millisecond,
WorkerCount: 1,
}
forwarder := NewObservabilityForwarder(store, cfg)
err := forwarder.Start(ctx)
require.NoError(t, err)
defer forwarder.Stop(ctx)
// Publish event - should NOT be forwarded
events.PublishExecutionCreated("exec-reload-1", "wf-1", "agent-1", nil)
time.Sleep(200 * time.Millisecond)
require.Equal(t, int32(0), atomic.LoadInt32(&receivedCount), "no events should be forwarded when disabled")
// Enable webhook
config := &types.ObservabilityWebhookConfig{
ID: "global",
URL: server.URL,
Enabled: true,
}
err = store.SetObservabilityWebhook(ctx, config)
require.NoError(t, err)
// Reload config
err = forwarder.ReloadConfig(ctx)
require.NoError(t, err)
// Verify status
status := forwarder.GetStatus()
require.True(t, status.Enabled)
require.Equal(t, server.URL, status.WebhookURL)
// Publish event - should be forwarded now
events.PublishExecutionCreated("exec-reload-2", "wf-1", "agent-1", nil)
time.Sleep(200 * time.Millisecond)
require.Greater(t, atomic.LoadInt32(&receivedCount), int32(0), "events should be forwarded after enabling")
}
// TestIntegration_HeartbeatFiltering verifies that heartbeat events are filtered
// and not forwarded to the webhook.
func TestIntegration_HeartbeatFiltering(t *testing.T) {
store, ctx := setupIntegrationTest(t)
var (
mu sync.Mutex
receivedEvents []types.ObservabilityEvent
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var batch types.ObservabilityEventBatch
json.Unmarshal(body, &batch)
mu.Lock()
receivedEvents = append(receivedEvents, batch.Events...)
mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
config := &types.ObservabilityWebhookConfig{
ID: "global",
URL: server.URL,
Enabled: true,
}
err := store.SetObservabilityWebhook(ctx, config)
require.NoError(t, err)
cfg := ObservabilityForwarderConfig{
BatchSize: 10,
BatchTimeout: 200 * time.Millisecond,
WorkerCount: 1,
}
forwarder := NewObservabilityForwarder(store, cfg)
err = forwarder.Start(ctx)
require.NoError(t, err)
defer forwarder.Stop(ctx)
// Publish mix of real events and heartbeats
events.PublishNodeOnline("node-filter-1", nil)
events.PublishNodeHeartbeat() // Should be filtered
events.PublishReasonerOnline("reasoner-1", "node-1", nil)
events.PublishHeartbeat() // Should be filtered
events.PublishNodeOffline("node-filter-1", nil)
events.PublishNodeHeartbeat() // Should be filtered
// Wait for batch
time.Sleep(500 * time.Millisecond)
mu.Lock()
defer mu.Unlock()
// Verify no heartbeat events
for _, event := range receivedEvents {
require.NotEqual(t, "heartbeat", event.EventType, "reasoner heartbeat should be filtered")
require.NotEqual(t, "node_heartbeat", event.EventType, "node heartbeat should be filtered")
}
// Should have received exactly 3 real events
require.Equal(t, 3, len(receivedEvents), "should have 3 non-heartbeat events")
}
// TestIntegration_SignatureVerification tests that HMAC signatures are correctly
// generated and can be verified.
func TestIntegration_SignatureVerification(t *testing.T) {
store, ctx := setupIntegrationTest(t)
secret := "test-verification-secret"
var (
mu sync.Mutex
receivedSig string
receivedBody []byte
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
receivedSig = r.Header.Get("X-HanzoAgents-Signature")
receivedBody, _ = io.ReadAll(r.Body)
mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
config := &types.ObservabilityWebhookConfig{
ID: "global",
URL: server.URL,
Secret: &secret,
Enabled: true,
}
err := store.SetObservabilityWebhook(ctx, config)
require.NoError(t, err)
cfg := ObservabilityForwarderConfig{
BatchSize: 1,
BatchTimeout: 50 * time.Millisecond,
WorkerCount: 1,
}
forwarder := NewObservabilityForwarder(store, cfg)
err = forwarder.Start(ctx)
require.NoError(t, err)
defer forwarder.Stop(ctx)
events.PublishExecutionCreated("exec-sig-verify", "wf-1", "agent-1", nil)
time.Sleep(300 * time.Millisecond)
mu.Lock()
defer mu.Unlock()
require.NotEmpty(t, receivedSig)
require.Contains(t, receivedSig, "sha256=")
require.NotEmpty(t, receivedBody)
// Verify signature
expectedSig := generateObservabilitySignature(secret, receivedBody)
require.Equal(t, expectedSig, receivedSig, "signature should match")
}
// TestIntegration_BatchAggregation tests that events are correctly aggregated
// into batches.
func TestIntegration_BatchAggregation(t *testing.T) {
store, ctx := setupIntegrationTest(t)
var (
mu sync.Mutex
batchSizes []int
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var batch types.ObservabilityEventBatch
json.Unmarshal(body, &batch)
mu.Lock()
batchSizes = append(batchSizes, batch.EventCount)
mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
config := &types.ObservabilityWebhookConfig{
ID: "global",
URL: server.URL,
Enabled: true,
}
err := store.SetObservabilityWebhook(ctx, config)
require.NoError(t, err)
cfg := ObservabilityForwarderConfig{
BatchSize: 5, // Wait for 5 events
BatchTimeout: 10 * time.Second, // Long timeout
WorkerCount: 1,
}
forwarder := NewObservabilityForwarder(store, cfg)
err = forwarder.Start(ctx)
require.NoError(t, err)
defer forwarder.Stop(ctx)
// Send exactly 5 events to trigger batch
for i := 0; i < 5; i++ {
events.PublishExecutionCreated("exec-batch-"+string(rune('a'+i)), "wf-1", "agent-1", nil)
}
time.Sleep(300 * time.Millisecond)
mu.Lock()
defer mu.Unlock()
require.GreaterOrEqual(t, len(batchSizes), 1, "should have at least one batch")
require.Equal(t, 5, batchSizes[0], "first batch should have 5 events")
}
// TestIntegration_StoragePersistence tests that webhook configuration persists
// across forwarder restarts.
func TestIntegration_StoragePersistence(t *testing.T) {
store, ctx := setupIntegrationTest(t)
var receivedCount int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&receivedCount, 1)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Configure webhook
secret := "persistent-secret"
config := &types.ObservabilityWebhookConfig{
ID: "global",
URL: server.URL,
Secret: &secret,
Headers: map[string]string{
"X-Persistent": "true",
},
Enabled: true,
}
err := store.SetObservabilityWebhook(ctx, config)
require.NoError(t, err)
cfg := ObservabilityForwarderConfig{
BatchSize: 1,
BatchTimeout: 50 * time.Millisecond,
WorkerCount: 1,
}
// Start first forwarder instance
forwarder1 := NewObservabilityForwarder(store, cfg)
err = forwarder1.Start(ctx)
require.NoError(t, err)
events.PublishExecutionCreated("exec-persist-1", "wf-1", "agent-1", nil)
time.Sleep(200 * time.Millisecond)
// Stop first forwarder
err = forwarder1.Stop(ctx)
require.NoError(t, err)
// Start second forwarder instance (simulates restart)
forwarder2 := NewObservabilityForwarder(store, cfg)
err = forwarder2.Start(ctx)
require.NoError(t, err)
defer forwarder2.Stop(ctx)
// Verify config was loaded from storage
status := forwarder2.GetStatus()
require.True(t, status.Enabled)
require.Equal(t, server.URL, status.WebhookURL)
// Verify new forwarder can forward events
beforeCount := atomic.LoadInt32(&receivedCount)
events.PublishExecutionCreated("exec-persist-2", "wf-1", "agent-1", nil)
time.Sleep(200 * time.Millisecond)
require.Greater(t, atomic.LoadInt32(&receivedCount), beforeCount, "new forwarder should forward events")
}