diff --git a/cmd/root/otel.go b/cmd/root/otel.go index 5effecd1a..9fc1f044d 100644 --- a/cmd/root/otel.go +++ b/cmd/root/otel.go @@ -10,6 +10,7 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/sdk/resource" "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.40.0" @@ -65,6 +66,14 @@ func initOTelSDK(ctx context.Context) (err error) { tp := trace.NewTracerProvider(tracerProviderOpts...) otel.SetTracerProvider(tp) + // Propagator must be set so otelhttp injects W3C traceparent on + // outbound requests and extracts it from incoming ones. Without this + // the SDK records spans locally but they never chain across services. + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) diff --git a/go.mod b/go.mod index 4ef1ada7c..eb878e3c0 100644 --- a/go.mod +++ b/go.mod @@ -221,7 +221,7 @@ require ( github.com/yuin/goldmark-emoji v1.0.5 // indirect go.etcd.io/bbolt v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect diff --git a/pkg/httpclient/client.go b/pkg/httpclient/client.go index 8cfa4fe11..0c29e7d6d 100644 --- a/pkg/httpclient/client.go +++ b/pkg/httpclient/client.go @@ -6,8 +6,11 @@ import ( "maps" "net/http" "net/url" + "os" "runtime" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "github.com/docker/docker-agent/pkg/remote" "github.com/docker/docker-agent/pkg/version" ) @@ -97,7 +100,15 @@ func WithQuery(query url.Values) Opt { } } -// newTransport returns an HTTP transport with automatic gzip compression disabled and using Docker Desktop proxy if available. +// newTransport returns an HTTP transport with automatic gzip compression +// disabled and using Docker Desktop proxy if available. +// +// When OpenTelemetry is enabled (i.e. OTEL_EXPORTER_OTLP_ENDPOINT is set, +// matching the gating in initOTelSDK), the transport is wrapped with +// otelhttp so each outbound request emits a CLIENT span and the W3C +// traceparent header is injected. When OTel is disabled, the bare +// transport is returned so we don't allocate per-request spans nor send +// a traceparent header to upstream LLM providers. func newTransport(ctx context.Context) http.RoundTripper { // Get the base transport with Desktop proxy support from remote package rt := remote.NewTransport(ctx) @@ -105,10 +116,22 @@ func newTransport(ctx context.Context) http.RoundTripper { // If it's an http.Transport, disable compression for SSE streaming compatibility if transport, ok := rt.(*http.Transport); ok { transport.DisableCompression = true - return transport + rt = transport } - return rt + return WrapWithOTel(rt) +} + +// WrapWithOTel returns rt wrapped with otelhttp when OpenTelemetry is +// enabled (OTEL_EXPORTER_OTLP_ENDPOINT set, matching the gating in +// cmd/root/otel.go), or rt unchanged otherwise. Exposed so callers that +// build their own transports outside of NewHTTPClient can opt into the +// same env-gated instrumentation without duplicating the gating logic. +func WrapWithOTel(rt http.RoundTripper) http.RoundTripper { + if os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") == "" { + return rt + } + return otelhttp.NewTransport(rt) } type userAgentTransport struct {