diff --git a/pkg/gotenberg/internal/otel/otel.go b/pkg/gotenberg/internal/otel/otel.go index fe02c8f4..87bbe8ba 100644 --- a/pkg/gotenberg/internal/otel/otel.go +++ b/pkg/gotenberg/internal/otel/otel.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "os" + "strings" "sync/atomic" "go.opentelemetry.io/contrib/bridges/otelslog" @@ -74,6 +75,24 @@ func buildResource(ctx context.Context, logger *slog.Logger, serviceName, servic return merged } +// OTEL_*_EXPORTER select the exporter for each signal. autoexport treats an +// unset or empty value as a request for the OTLP exporter, which then fails +// against the default localhost:4318 endpoint when nothing listens there and, +// for metrics, keeps retrying on the periodic reader's timer. Gotenberg keeps +// telemetry opt-in: a signal with no exporter configured is built without one +// and stays inert. See https://github.com/gotenberg/gotenberg/issues/1643. +const ( + tracesExporterEnvKey = "OTEL_TRACES_EXPORTER" + metricsExporterEnvKey = "OTEL_METRICS_EXPORTER" + logsExporterEnvKey = "OTEL_LOGS_EXPORTER" +) + +// exporterConfigured reports whether the operator selected an exporter for the +// signal owning envKey. An unset or blank value keeps that signal off. +func exporterConfigured(envKey string) bool { + return strings.TrimSpace(os.Getenv(envKey)) != "" +} + // InitTracerProvider initializes the OpenTelemetry tracer provider. func InitTracerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, err error) { initOtelLogger(logger) @@ -86,13 +105,14 @@ func InitTracerProvider(logger *slog.Logger, serviceName, serviceVersion string) trace.WithResource(res), } - traceExporter, err := autoexport.NewSpanExporter(ctx) - if err != nil { - return nil, err - } - - if !autoexport.IsNoneSpanExporter(traceExporter) { - traceOpts = append(traceOpts, trace.WithBatcher(traceExporter)) + if exporterConfigured(tracesExporterEnvKey) { + traceExporter, err := autoexport.NewSpanExporter(ctx) + if err != nil { + return nil, err + } + if !autoexport.IsNoneSpanExporter(traceExporter) { + traceOpts = append(traceOpts, trace.WithBatcher(traceExporter)) + } } traceProvider := trace.NewTracerProvider(traceOpts...) @@ -119,13 +139,14 @@ func InitMeterProvider(logger *slog.Logger, serviceName, serviceVersion string) } metricOpts = append(metricOpts, exemplarFilterOptions()...) - metricReader, err := autoexport.NewMetricReader(ctx) - if err != nil { - return nil, err - } - - if !autoexport.IsNoneMetricReader(metricReader) { - metricOpts = append(metricOpts, metric.WithReader(metricReader)) + if exporterConfigured(metricsExporterEnvKey) { + metricReader, err := autoexport.NewMetricReader(ctx) + if err != nil { + return nil, err + } + if !autoexport.IsNoneMetricReader(metricReader) { + metricOpts = append(metricOpts, metric.WithReader(metricReader)) + } } meterProvider := metric.NewMeterProvider(metricOpts...) @@ -157,13 +178,14 @@ func InitLoggerProvider(logger *slog.Logger, serviceName, serviceVersion string) log.WithResource(res), } - logExporter, err := autoexport.NewLogExporter(ctx) - if err != nil { - return nil, nil, err - } - - if !autoexport.IsNoneLogExporter(logExporter) { - logOpts = append(logOpts, log.WithProcessor(log.NewBatchProcessor(logExporter))) + if exporterConfigured(logsExporterEnvKey) { + logExporter, err := autoexport.NewLogExporter(ctx) + if err != nil { + return nil, nil, err + } + if !autoexport.IsNoneLogExporter(logExporter) { + logOpts = append(logOpts, log.WithProcessor(log.NewBatchProcessor(logExporter))) + } } loggerProvider := log.NewLoggerProvider(logOpts...) diff --git a/pkg/gotenberg/internal/otel/otel_test.go b/pkg/gotenberg/internal/otel/otel_test.go index 3873d813..92f990c8 100644 --- a/pkg/gotenberg/internal/otel/otel_test.go +++ b/pkg/gotenberg/internal/otel/otel_test.go @@ -89,6 +89,49 @@ func TestInitTracerProvider_HonorsSamplerEnv(t *testing.T) { } } +// TestExporterConfigured pins the opt-in gate: an unset or blank +// OTEL_*_EXPORTER keeps the signal off, so Gotenberg never wires the OTLP +// exporter that autoexport would otherwise default to and fail to reach at +// localhost:4318. See https://github.com/gotenberg/gotenberg/issues/1643. +func TestExporterConfigured(t *testing.T) { + const key = "OTEL_METRICS_EXPORTER" + + orig, had := os.LookupEnv(key) + t.Cleanup(func() { + if had { + os.Setenv(key, orig) + return + } + os.Unsetenv(key) + }) + + for _, tc := range []struct { + scenario string + unset bool + value string + want bool + }{ + {"unset", true, "", false}, + {"empty", false, "", false}, + {"whitespace only", false, " ", false}, + {"none", false, "none", true}, + {"otlp", false, "otlp", true}, + {"padded value", false, " otlp ", true}, + } { + t.Run(tc.scenario, func(t *testing.T) { + if tc.unset { + os.Unsetenv(key) + } else { + os.Setenv(key, tc.value) + } + + if got := exporterConfigured(key); got != tc.want { + t.Errorf("exporterConfigured(%q) = %v, want %v", key, got, tc.want) + } + }) + } +} + func TestExemplarFilterOptions(t *testing.T) { t.Run("default pins trace-based", func(t *testing.T) { if v, ok := os.LookupEnv("OTEL_METRICS_EXEMPLAR_FILTER"); ok {