diff --git a/tracing/tracing.go b/tracing/tracing.go index 9dc7914ee..4e39a7ab8 100644 --- a/tracing/tracing.go +++ b/tracing/tracing.go @@ -3,6 +3,7 @@ package tracing import ( "context" "fmt" + "sync" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" @@ -25,12 +26,39 @@ type Config struct { Endpoint string `yaml:"endpoint" json:"endpoint"` // Insecure determines whether to use an insecure connection (no TLS) Insecure bool `yaml:"insecure" json:"insecure"` + // Enabled explicitly controls whether tracing is active. When nil, + // tracing is enabled only if Endpoint is set. When explicitly false, + // tracing is always disabled regardless of Endpoint. + Enabled *bool `yaml:"enabled" json:"enabled"` +} + +var ( + exportErrMu sync.Mutex + wasFailing bool + previousHandler otel.ErrorHandler +) + +// suppressRepeatedExportErrors logs the first export failure encountered, +// then stays quiet on subsequent failures, to avoid flooding logs when a +// configured collector endpoint is unreachable. Retries by the underlying +// exporter continue unaffected; only the logging is suppressed. +func suppressRepeatedExportErrors(err error) { + exportErrMu.Lock() + if !wasFailing { + fmt.Printf("tracing export failing, collector may be unreachable: %v\n", err) + wasFailing = true + } + exportErrMu.Unlock() + + if previousHandler != nil { + previousHandler.Handle(err) + } } func InitTracerFromYamlConfig(ctx context.Context, config string) (*sdktrace.TracerProvider, error) { cfg := Config{} - err := yaml.Unmarshal([]byte(config),&cfg) + err := yaml.Unmarshal([]byte(config), &cfg) if err != nil { return nil, fmt.Errorf("failed to parse tracing config: %w", err) @@ -42,13 +70,22 @@ func InitTracerFromYamlConfig(ctx context.Context, config string) (*sdktrace.Tra // InitTracer initializes and configures the global OpenTelemetry trace provider // It sets up OTLP gRPC exporter, resource attributes, and W3C trace context propagation func InitTracer(ctx context.Context, cfg Config) (*sdktrace.TracerProvider, error) { + // Tracing is a no-op if explicitly disabled, or if no endpoint is + // configured — this is not an error, just an intentional off state. + if (cfg.Enabled != nil && !*cfg.Enabled) || cfg.Endpoint == "" { + return nil, nil + } + // Validate configuration if cfg.ServiceName == "" { return nil, fmt.Errorf("service name is required") } - if cfg.Endpoint == "" { - return nil, fmt.Errorf("endpoint is required") - } + + // Suppress repeated identical export-failure logs (e.g. when the + // configured endpoint is unreachable) so noisy retries don't flood logs. + // Preserves and still invokes any previously-registered handler. + previousHandler = otel.GetErrorHandler() + otel.SetErrorHandler(otel.ErrorHandlerFunc(suppressRepeatedExportErrors)) // Configure OTLP exporter options opts := []otlptracegrpc.Option{ diff --git a/tracing/tracing_test.go b/tracing/tracing_test.go index b4ef7fcda..135c27894 100644 --- a/tracing/tracing_test.go +++ b/tracing/tracing_test.go @@ -14,10 +14,14 @@ import ( func TestInitTracer(t *testing.T) { // Note: These tests validate configuration without attempting real connections + falseVal := false + trueVal := true + tests := []struct { - name string - config Config - wantErr bool + name string + config Config + wantErr bool + wantNilProv bool }{ { name: "missing service name", @@ -29,25 +33,78 @@ func TestInitTracer(t *testing.T) { wantErr: true, }, { - name: "missing endpoint", + name: "missing endpoint is a no-op, not an error", config: Config{ ServiceName: "test-service", ServiceVersion: "1.0.0", Insecure: true, }, - wantErr: true, + wantErr: false, + wantNilProv: true, + }, + { + name: "explicitly disabled is a no-op, even with endpoint set", + config: Config{ + ServiceName: "test-service", + Endpoint: "localhost:4317", + Enabled: &falseVal, + }, + wantErr: false, + wantNilProv: true, + }, + { + name: "explicitly enabled with empty endpoint is still a no-op", + config: Config{ + ServiceName: "test-service", + Enabled: &trueVal, + }, + wantErr: false, + wantNilProv: true, + }, + { + name: "enabled with valid endpoint returns a real provider", + config: Config{ + ServiceName: "test-service", + Endpoint: "localhost:4317", + Insecure: true, + Enabled: &trueVal, + }, + wantErr: false, + wantNilProv: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + prevProvider := otel.GetTracerProvider() + prevHandler := otel.GetErrorHandler() + prevPropagator := otel.GetTextMapPropagator() + t.Cleanup(func() { + otel.SetTracerProvider(prevProvider) + otel.SetErrorHandler(prevHandler) + otel.SetTextMapPropagator(prevPropagator) + }) ctx := context.Background() - _, err := InitTracer(ctx, tt.config) + tp, err := InitTracer(ctx, tt.config) if (err != nil) != tt.wantErr { t.Errorf("InitTracer() error = %v, wantErr %v", err, tt.wantErr) return } + // Only check provider state when no error was expected — + // error cases always return a nil provider, which is correct + // but not meaningfully described by wantNilProv. + if !tt.wantErr { + if tt.wantNilProv && tp != nil { + t.Errorf("InitTracer() expected nil provider, got %v", tp) + } + if !tt.wantNilProv && tp == nil { + t.Errorf("InitTracer() expected non-nil provider, got nil") + } + } + if tp != nil { + _ = tp.Shutdown(ctx) + } }) } }