Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion go/cmd/compass-runner/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ import (
"os/signal"
"strings"
"syscall"
"time"

"connectrpc.com/connect"

"github.com/RigelBuild/compass/go/internal/agentuid"
"github.com/RigelBuild/compass/go/internal/otel"
"github.com/RigelBuild/compass/go/internal/runner"
"github.com/RigelBuild/compass/go/internal/runtime"
)
Expand All @@ -37,7 +39,7 @@ func main() {
}
}

func run() error {
func run() error { //nolint:funlen // flag registration + operator-input validation is the honest bulk; the env-only OTel setup call tips it 2 lines over — extracting the flags would scatter ~15 flag vars for no readability gain
runnerID := flag.String("runner-id", "",
"This Runner's stable id, cross-checked against the token subject. Defaults to $COMPASS_RUNNER_ID.")
serverAddr := flag.String("server", "",
Expand Down Expand Up @@ -158,6 +160,13 @@ func run() error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

// OTel emission (env-only, bounded flush on drain); see setupOtel.
otelShutdown, err := setupOtel(ctx)
if err != nil {
return err
}
defer otelShutdown()

return runner.Run(ctx, runner.RunnerConfig{
RunnerID: id,
ServerAddr: addr,
Expand All @@ -169,6 +178,41 @@ func run() error {
}, specs, log)
}

// setupOtel installs the tracer and meter providers off the env-only OTLP
// endpoint, returning one shutdown that flushes both. When
// OTEL_EXPORTER_OTLP_ENDPOINT is empty the providers are no-ops and the shutdown
// is a no-op, so tracing is off with zero overhead. The export gate lives here in
// the global-provider install — the runner's outbound otelconnect interceptor is
// mounted unconditionally and is inert against the no-op global.
func setupOtel(ctx context.Context) (shutdown func(), err error) {
cfg := otel.Config{
ServiceName: "compass-runner",
ServiceVersion: version,
Endpoint: os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"),
}
tracerShutdown, err := otel.SetupTracerProvider(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("otel: tracer provider: %w", err)
}
meterShutdown, err := otel.SetupMeterProvider(ctx, cfg)
if err != nil {
_ = tracerShutdown(ctx) // roll back the tracer provider we just installed; nothing actionable on its error here
return nil, fmt.Errorf("otel: meter provider: %w", err)
}
// The drain ctx is already cancelled by the time this fires (the signal that
// ends runner.Run is the same one that cancels ctx), so a raw ctx.Shutdown
// would abort its final ForceFlush and drop the last batch. Sever the
// cancellation and bound the flush at 2s (design.md: mirror the agent's 2s
// shutdown bound), derived at fire time so the deadline is not consumed by the
// process lifetime — matching the context.WithoutCancel precedent in run.go.
return func() {
sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
defer cancel()
_ = tracerShutdown(sctx) // best-effort flush on shutdown; export error not actionable at exit
_ = meterShutdown(sctx) // best-effort flush on shutdown; export error not actionable at exit
}, nil
}

// backendFlags holds the runtime-backend selection flags, registered on the
// default flag set before flag.Parse and resolved into a runtime after it.
type backendFlags struct {
Expand Down
120 changes: 120 additions & 0 deletions go/internal/runner/otel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//go:build unix

package runner

// OTel wiring tests for the Runner-side seam: Dial's outbound RunnerService
// client mounts the otelconnect interceptor, which emits a CLIENT span per RPC
// when a global tracer provider is installed — and none when disabled, since the
// empty-endpoint path installs no global provider (the export gate lives in the
// provider install, not in RunnerConfig).

import (
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/RigelBuild/compass/go/internal/gen/compass/v1/compassv1internalconnect"
"go.opentelemetry.io/otel"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"go.opentelemetry.io/otel/trace"
"go.opentelemetry.io/otel/trace/noop"
)

// enrollServerURL stands up an h2c httptest RunnerService serving enrollStub and
// returns its base URL, torn down via t.Cleanup — so a test can drive Dial (which
// builds the interceptor-wrapped client) end to end against a real dial.
func enrollServerURL(t *testing.T) string {
t.Helper()
path, handler := compassv1internalconnect.NewRunnerServiceHandler(enrollStub{})
mux := http.NewServeMux()
mux.Handle(path, handler)
srv := httptest.NewUnstartedServer(mux)
srv.Config.Protocols = cleartextHTTP2()
srv.Start()
t.Cleanup(srv.Close)
return srv.URL
}

// installInMemoryTracer installs an SDK tracer provider backed by an in-memory
// recorder as the global provider (the source otelconnect.NewInterceptor reads),
// restoring the prior global on cleanup. It returns the recorder.
func installInMemoryTracer(t *testing.T) *tracetest.SpanRecorder {
t.Helper()
rec := tracetest.NewSpanRecorder()
tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(rec))
prev := otel.GetTracerProvider()
otel.SetTracerProvider(tp)
t.Cleanup(func() {
otel.SetTracerProvider(prev)
_ = tp.Shutdown(context.Background()) // best-effort flush; test root ctx, error not actionable
})
return rec
}

// clientSpanCount counts CLIENT-kind spans among the recorded spans.
func clientSpanCount(spans []sdktrace.ReadOnlySpan) int {
n := 0
for _, s := range spans {
if s.SpanKind() == trace.SpanKindClient {
n++
}
}
return n
}

// TestDialEmitsClientSpanWhenEnabled asserts Dial's outbound client emits a
// CLIENT span on the Enroll RPC when a global tracer provider is installed.
func TestDialEmitsClientSpanWhenEnabled(t *testing.T) {
rec := installInMemoryTracer(t)
url := enrollServerURL(t)

// context.Background() is the test root context.
if _, err := Dial(context.Background(), RunnerConfig{
RunnerID: "r-1",
ServerAddr: url,
Token: "tok",
HTTPClient: h2cHTTPClient(t),
// Emission gated by the installed global tracer provider, not any config
// field — installInMemoryTracer set one above.
}); err != nil {
t.Fatalf("Dial err = %v, want nil", err)
}

if got := clientSpanCount(rec.Ended()); got == 0 {
t.Fatalf("enabled: client spans = %d, want >= 1 (otelconnect emits a CLIENT span per RPC)", got)
}
}

// TestDialEmitsNoClientSpanWhenDisabled asserts that with no SDK provider
// installed (the empty-endpoint disabled path), the same dial records no spans —
// the otelconnect interceptor is a no-op against the global noop provider.
func TestDialEmitsNoClientSpanWhenDisabled(t *testing.T) {
// Pin the global to a noop provider (the disabled-path state: SetupTracerProvider
// installs nothing when the endpoint is empty), and record via a separate SDK
// provider that is NOT global — so any span the interceptor emits would be caught,
// yet none is, because otelconnect reads the (noop) global.
prev := otel.GetTracerProvider()
otel.SetTracerProvider(noop.NewTracerProvider())
t.Cleanup(func() { otel.SetTracerProvider(prev) })

rec := tracetest.NewSpanRecorder()
sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(rec)) // deliberately not made global

url := enrollServerURL(t)

// context.Background() is the test root context.
if _, err := Dial(context.Background(), RunnerConfig{
RunnerID: "r-1",
ServerAddr: url,
Token: "tok",
HTTPClient: h2cHTTPClient(t),
}); err != nil {
t.Fatalf("Dial err = %v, want nil", err)
}

if got := clientSpanCount(rec.Ended()); got != 0 {
t.Fatalf("disabled: client spans = %d, want 0 (no global SDK provider installed)", got)
}
}
9 changes: 8 additions & 1 deletion go/internal/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"net/http"

"connectrpc.com/connect"
"connectrpc.com/otelconnect"

compassv1internal "github.com/RigelBuild/compass/go/internal/gen/compass/v1"
"github.com/RigelBuild/compass/go/internal/gen/compass/v1/compassv1internalconnect"
Expand Down Expand Up @@ -103,9 +104,15 @@ func Dial(ctx context.Context, cfg RunnerConfig) (*ServerLink, error) {
if httpClient == nil {
httpClient = http.DefaultClient
}
otelInterceptor, err := otelconnect.NewInterceptor()
if err != nil {
return nil, fmt.Errorf("otel: connect interceptor: %w", err)
}
client := compassv1internalconnect.NewRunnerServiceClient(
httpClient, cfg.ServerAddr,
connect.WithInterceptors(&bearerToken{token: cfg.Token}),
// otelconnect goes first (outermost) so enroll/Sessions dials emit
// client spans; it is a no-op when no global provider is installed.
connect.WithInterceptors(otelInterceptor, &bearerToken{token: cfg.Token}),
)
resp, err := client.Enroll(ctx, connect.NewRequest(&compassv1internal.EnrollRequest{
RunnerId: cfg.RunnerID,
Expand Down
Loading