// 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" "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" logf "sigs.k8s.io/controller-runtime/pkg/log" ) const ( envSDKDisabled = "OTEL_SDK_DISABLED" envTracesExporter = "OTEL_TRACES_EXPORTER" envOTLPEndpoint = "OTEL_EXPORTER_OTLP_ENDPOINT" envOTLPTracesEndpoint = "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT" envOTLPProtocol = "OTEL_EXPORTER_OTLP_PROTOCOL" envOTLPTracesProtocol = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL" exporterOTLP = "otlp" exporterConsole = "console" exporterNone = "none" ) // 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_*). // // Setup logs via the logger in ctx (logf.FromContext) rather than a // parameter — the caller seeds it with logf.IntoContext. func Setup(ctx context.Context, service, version string) (func(context.Context) error, error) { log := logf.FromContext(ctx) noop := func(context.Context) error { return nil } exporterEnv := strings.ToLower(strings.TrimSpace(os.Getenv(envTracesExporter))) endpointSet := os.Getenv(envOTLPEndpoint) != "" || os.Getenv(envOTLPTracesEndpoint) != "" sdkDisabled := strings.EqualFold(strings.TrimSpace(os.Getenv(envSDKDisabled)), "true") if sdkDisabled || exporterEnv == exporterNone || (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 == exporterNone: 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 "", exporterOTLP: proto := strings.ToLower(strings.TrimSpace(os.Getenv(envOTLPTracesProtocol))) if proto == "" { proto = strings.ToLower(strings.TrimSpace(os.Getenv(envOTLPProtocol))) } 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 exporterConsole: exp, err := stdouttrace.New() return exp, exporterConsole, 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) }