Files
synapbus/internal/observability/otel_test.go
T
Algis DumbrisandClaude Opus 4.6 574897df4d feat: harness-agnostic wrappers + OpenTelemetry integration
Introduces internal/harness — a minimal Harness interface inspired by
GoogleCloudPlatform/scion — plus four backends (k8sjob, subprocess,
webhook, stub) and an OTel-traced Registry that spans every dispatch
and injects W3C trace context into child processes via env vars.

Phases landed together on this branch:

  1. internal/harness scaffold: Harness/Capabilities/ExecRequest/
     ExecResult/Budget/Usage types, Registry with Resolve/Execute,
     in-memory stub backend.
  2. internal/harness/k8sjob: wraps existing k8s.JobRunner behind the
     Harness interface with a Waiter abstraction (real clientset +
     test fake). BuildHandler exports the per-agent config logic.
  3. internal/harness/subprocess: os/exec-based backend (Mac+Linux),
     per-run workdir, result.json handoff, bounded log capture,
     Budget-driven wall-clock timeout.
  4. internal/harness/webhook: synchronous HTTP POST with HMAC
     signing via internal/webhooks.ComputeHMACSignature, per-agent
     URL/secret/timeout read from harness_config_json.
  5. internal/observability: OTel tracer init via OTLP HTTP (opt-in
     via SYNAPBUS_OTEL_ENABLED), W3C propagator always installed;
     Registry.Execute starts a harness.execute span per dispatch and
     calls InjectTraceContext into req.Env so children inherit it.
  6. internal/harness/runs: SQLite-backed Observer that persists a
     harness_runs row per dispatch with status, usage, cost, duration,
     trace_id, session_id, and a bounded logs excerpt.

Schema: new migration 019_harness.sql adds agents.harness_name /
local_command / harness_config_json columns and the backend-agnostic
harness_runs table with indices on (agent, created_at), (status),
(trace_id), (run_id). internal/reactor/reactor_test.go inline schema
updated to match.

Deployment: deploy/kubic/otel-collector.yaml stands up an otel-collector
Deployment + ConfigMap + ClusterIP Service in the synapbus namespace on
kubic, receiving OTLP gRPC (4317) and HTTP (4318) and exporting debug
output until a Tempo/Jaeger backend lands.

Docs: docs/harness-otel-research.html compares scion and paperclip
side-by-side and maps the current synapbus executor surface; its
companion docs/harness-otel-design.md carries the phase plan, span
taxonomy, and migration schema verbatim.

The reactor currently still calls k8s.JobRunner directly — rewiring it
through the Registry is a follow-up, intentionally out of scope for
this branch to keep the refactor reversible. The new packages are
independently tested (~78 new tests across 7 packages) and the full
project test suite passes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:23:04 +03:00

100 lines
3.0 KiB
Go

package observability_test
import (
"context"
"testing"
"github.com/synapbus/synapbus/internal/observability"
"go.opentelemetry.io/otel"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
)
func TestConfigFromEnv_DefaultDisabled(t *testing.T) {
cfg := observability.ConfigFromEnv(func(string) string { return "" })
if cfg.Enabled {
t.Error("default Enabled should be false")
}
if cfg.ServiceName != "synapbus" {
t.Errorf("ServiceName default = %q, want synapbus", cfg.ServiceName)
}
if !cfg.Insecure {
t.Error("default Insecure should be true (LAN-friendly)")
}
}
func TestConfigFromEnv_Overrides(t *testing.T) {
env := map[string]string{
"SYNAPBUS_OTEL_ENABLED": "1",
"SYNAPBUS_OTEL_ENDPOINT": "otel.kubic.home.arpa:4318",
"SYNAPBUS_OTEL_INSECURE": "false",
"SYNAPBUS_OTEL_SERVICE_NAME": "synapbus-dev",
}
cfg := observability.ConfigFromEnv(func(k string) string { return env[k] })
if !cfg.Enabled {
t.Error("Enabled=1 not honoured")
}
if cfg.Endpoint != "otel.kubic.home.arpa:4318" {
t.Errorf("Endpoint = %q", cfg.Endpoint)
}
if cfg.Insecure {
t.Error("Insecure=false not honoured")
}
if cfg.ServiceName != "synapbus-dev" {
t.Errorf("ServiceName = %q", cfg.ServiceName)
}
}
func TestInit_DisabledIsNoopShutdown(t *testing.T) {
shutdown, err := observability.Init(context.Background(), observability.Config{Enabled: false}, nil)
if err != nil {
t.Fatalf("Init err = %v", err)
}
if shutdown == nil {
t.Fatal("shutdown is nil")
}
if err := shutdown(context.Background()); err != nil {
t.Fatalf("shutdown err = %v", err)
}
}
func TestInjectTraceContext_NoSpan(t *testing.T) {
dst := map[string]string{}
observability.InjectTraceContext(context.Background(), dst)
// No span → no traceparent written (TextMapPropagator sees empty
// span context). Assert the function didn't panic; dst may be empty
// or contain TRACEPARENT with all-zero trace id depending on setup.
}
func TestInjectTraceContext_WithSpan(t *testing.T) {
// Install a TracerProvider with an in-memory exporter so spans
// have real trace ids.
tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(tracetest.NewInMemoryExporter()))
t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
otel.SetTracerProvider(tp)
// Install the propagator (normally done by observability.Init)
_, _ = observability.Init(context.Background(), observability.Config{Enabled: false}, nil)
ctx, span := tp.Tracer("test").Start(context.Background(), "outer")
defer span.End()
dst := map[string]string{}
observability.InjectTraceContext(ctx, dst)
if _, ok := dst["TRACEPARENT"]; !ok {
t.Errorf("TRACEPARENT missing from dst: %v", dst)
}
tid := observability.TraceIDFromContext(ctx)
if tid == "" {
t.Error("TraceIDFromContext returned empty")
}
}
func TestTraceIDFromContext_NoSpan(t *testing.T) {
tid := observability.TraceIDFromContext(context.Background())
if tid != "" {
t.Errorf("TraceIDFromContext with no span = %q, want empty", tid)
}
}