diff --git a/pkg/gotenberg/internal/otel/otel.go b/pkg/gotenberg/internal/otel/otel.go index f4fca1b..804ab22 100644 --- a/pkg/gotenberg/internal/otel/otel.go +++ b/pkg/gotenberg/internal/otel/otel.go @@ -14,6 +14,7 @@ import ( "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/sdk/log" "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/exemplar" "go.opentelemetry.io/otel/sdk/resource" "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.41.0" @@ -92,6 +93,7 @@ func InitMeterProvider(logger *slog.Logger, serviceName, serviceVersion string) metricOpts := []metric.Option{ metric.WithResource(res), } + metricOpts = append(metricOpts, exemplarFilterOptions()...) metricReader, err := autoexport.NewMetricReader(ctx) if err != nil { @@ -108,6 +110,17 @@ func InitMeterProvider(logger *slog.Logger, serviceName, serviceVersion string) return meterProvider.Shutdown, nil } +// exemplarFilterOptions returns the meter provider options that pin trace-based +// exemplars, so the histograms expose the trace id of a representative +// measurement. It yields no option when the operator selects a filter via +// OTEL_METRICS_EXEMPLAR_FILTER, letting the SDK's own env handling win. +func exemplarFilterOptions() []metric.Option { + if _, ok := os.LookupEnv("OTEL_METRICS_EXEMPLAR_FILTER"); ok { + return nil + } + return []metric.Option{metric.WithExemplarFilter(exemplar.TraceBasedFilter)} +} + // InitLoggerProvider initializes the OpenTelemetry logger provider. func InitLoggerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, handler slog.Handler, err error) { initOtelLogger(logger) diff --git a/pkg/gotenberg/internal/otel/otel_test.go b/pkg/gotenberg/internal/otel/otel_test.go index 1a3ad64..a5751c9 100644 --- a/pkg/gotenberg/internal/otel/otel_test.go +++ b/pkg/gotenberg/internal/otel/otel_test.go @@ -3,9 +3,14 @@ package otel import ( "context" "log/slog" + "os" "testing" "go.opentelemetry.io/otel" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/exemplar" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + sdktrace "go.opentelemetry.io/otel/sdk/trace" ) // TestInitTracerProvider_HonorsSamplerEnv guards the contract that the tracer @@ -40,3 +45,71 @@ func TestInitTracerProvider_HonorsSamplerEnv(t *testing.T) { }) } } + +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 { + os.Unsetenv("OTEL_METRICS_EXEMPLAR_FILTER") + t.Cleanup(func() { os.Setenv("OTEL_METRICS_EXEMPLAR_FILTER", v) }) + } + if got := exemplarFilterOptions(); len(got) != 1 { + t.Errorf("expected 1 option when env unset, got %d", len(got)) + } + }) + + t.Run("env override yields no option", func(t *testing.T) { + t.Setenv("OTEL_METRICS_EXEMPLAR_FILTER", "always_off") + if got := exemplarFilterOptions(); len(got) != 0 { + t.Errorf("expected 0 options when env set, got %d", len(got)) + } + }) +} + +// TestMeterProvider_TraceBasedExemplar guards that the trace-based filter we pin +// actually attaches a trace id to a histogram measurement recorded inside a +// sampled span. +func TestMeterProvider_TraceBasedExemplar(t *testing.T) { + reader := sdkmetric.NewManualReader() + provider := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(reader), + sdkmetric.WithExemplarFilter(exemplar.TraceBasedFilter), + ) + t.Cleanup(func() { _ = provider.Shutdown(context.Background()) }) + + hist, err := provider.Meter("test").Float64Histogram("conversion.duration") + if err != nil { + t.Fatalf("create histogram: %v", err) + } + + tracer := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.AlwaysSample())).Tracer("test") + ctx, span := tracer.Start(context.Background(), "conversion") + hist.Record(ctx, 1.0) + traceID := span.SpanContext().TraceID() + span.End() + + var rm metricdata.ResourceMetrics + if err := reader.Collect(context.Background(), &rm); err != nil { + t.Fatalf("collect: %v", err) + } + + var found bool + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + hd, ok := m.Data.(metricdata.Histogram[float64]) + if !ok { + continue + } + for _, dp := range hd.DataPoints { + for _, ex := range dp.Exemplars { + if string(ex.TraceID) == string(traceID[:]) { + found = true + } + } + } + } + } + + if !found { + t.Error("expected a trace-based exemplar carrying the span trace id") + } +}