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>
100 lines
3.0 KiB
Go
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)
|
|
}
|
|
}
|