// Package tracing wires OpenTelemetry tracing into the operator: SDK setup // gated on standard OTEL_* environment variables, span helpers that keep the // logr/zap logging enriched with trace context, and decorators for the // reconciler, providers, HTTP server, and Kubernetes API transport. package tracing import ( "context" "fmt" "os" "strings" "github.com/go-logr/logr" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.43.0" ) // maxQueueSize bounds the batch processor's span buffer; the default 2048 // is oversized for this process (3 concurrent reconciles, tickers) and the // manager pod runs with a 128Mi memory limit. const maxQueueSize = 512 // Setup initializes the global tracing pipeline from OTEL_* environment // variables and returns a shutdown func that flushes pending spans. // // Tracing stays fully off — no exporter, no global provider, spans are // no-ops, logs carry no traceID — unless the environment opts in by setting // OTEL_TRACES_EXPORTER or an OTLP endpoint. OTEL_SDK_DISABLED=true and // OTEL_TRACES_EXPORTER=none force it off (the Go SDK does not implement // OTEL_SDK_DISABLED itself). Sampling, endpoints, TLS, and headers follow // the standard SDK/exporter env vars (OTEL_TRACES_SAMPLER, OTEL_EXPORTER_OTLP_*). func Setup(ctx context.Context, log logr.Logger, service, version string) (func(context.Context) error, error) { noop := func(context.Context) error { return nil } exporterEnv := strings.ToLower(strings.TrimSpace(os.Getenv("OTEL_TRACES_EXPORTER"))) endpointSet := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") != "" || os.Getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") != "" sdkDisabled := strings.EqualFold(strings.TrimSpace(os.Getenv("OTEL_SDK_DISABLED")), "true") if sdkDisabled || exporterEnv == "none" || (exporterEnv == "" && !endpointSet) { log.Info("tracing disabled", "reason", disabledReason(sdkDisabled, exporterEnv)) return noop, nil } exp, expName, err := newExporter(ctx, exporterEnv) if err != nil { return noop, fmt.Errorf("tracing setup: %w", err) } res, err := buildResource(ctx, service, version) if err != nil { if res == nil { return noop, fmt.Errorf("tracing setup: building resource: %w", err) } // Schema-URL conflicts between semconv versions still yield a usable // merged resource; keep it rather than losing tracing over metadata. log.V(1).Info("tracing resource merge conflict; continuing", "err", err.Error()) } tp := sdktrace.NewTracerProvider( sdktrace.WithBatcher(exp, sdktrace.WithMaxQueueSize(maxQueueSize)), sdktrace.WithResource(res), ) otel.SetTracerProvider(tp) otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( propagation.TraceContext{}, propagation.Baggage{})) otelLog := log.WithName("otel") otel.SetLogger(otelLog) // V(1), not Error: an unreachable collector fails every export cycle and // would otherwise spam the error log every few seconds. otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) { otelLog.V(1).Info("otel error", "err", err.Error()) })) log.Info("tracing enabled", "exporter", expName, "service", service) return tp.Shutdown, nil } func disabledReason(sdkDisabled bool, exporterEnv string) string { switch { case sdkDisabled: return "OTEL_SDK_DISABLED=true" case exporterEnv == "none": return "OTEL_TRACES_EXPORTER=none" default: return "no OTEL_TRACES_EXPORTER or OTLP endpoint configured" } } // newExporter hand-rolls the OTEL_TRACES_EXPORTER / OTEL_EXPORTER_OTLP_* // protocol selection instead of pulling in contrib's autoexport, which drags // metric/log/prometheus exporters into a trace-only binary. func newExporter(ctx context.Context, kind string) (sdktrace.SpanExporter, string, error) { switch kind { case "", "otlp": proto := strings.ToLower(strings.TrimSpace(os.Getenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"))) if proto == "" { proto = strings.ToLower(strings.TrimSpace(os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL"))) } switch proto { case "", "http/protobuf": exp, err := otlptracehttp.New(ctx) return exp, "otlp/http", err case "grpc": exp, err := otlptracegrpc.New(ctx) return exp, "otlp/grpc", err default: return nil, "", fmt.Errorf("unsupported OTLP protocol %q (supported: http/protobuf, grpc)", proto) } case "console": exp, err := stdouttrace.New() return exp, "console", err default: return nil, "", fmt.Errorf("unsupported OTEL_TRACES_EXPORTER %q (supported: otlp, console, none)", kind) } } // buildResource layers defaults < service identity < environment, so // OTEL_SERVICE_NAME / OTEL_RESOURCE_ATTRIBUTES always win. func buildResource(ctx context.Context, service, version string) (*resource.Resource, error) { base, err := resource.Merge(resource.Default(), resource.NewWithAttributes(semconv.SchemaURL, semconv.ServiceName(service), semconv.ServiceVersion(version), )) if err != nil { return base, err } env, err := resource.New(ctx, resource.WithFromEnv()) if err != nil { return base, err } return resource.Merge(base, env) }