diff --git a/pkg/telemetry/context_test.go b/pkg/telemetry/context_test.go index 3b25e746..b092ced8 100644 --- a/pkg/telemetry/context_test.go +++ b/pkg/telemetry/context_test.go @@ -15,18 +15,136 @@ package telemetry import ( + "context" + "encoding/json/v2" "testing" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/propagation" + sdktrace "go.opentelemetry.io/otel/sdk/trace" ) -func TestInjectCarrierClearsStalePropagationHeaders(t *testing.T) { +// setPropagator installs the composite propagator the package expects and +// restores the previous one when the test finishes. +func setPropagator(t *testing.T) { + t.Helper() + prev := otel.GetTextMapPropagator() otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( propagation.TraceContext{}, propagation.Baggage{}, )) + t.Cleanup(func() { otel.SetTextMapPropagator(prev) }) +} + +// recordingContext returns a context carrying a sampled span and the trace id +// of that span. +func recordingContext(t *testing.T) (context.Context, string) { + t.Helper() + tp := sdktrace.NewTracerProvider() + t.Cleanup(func() { _ = tp.Shutdown(context.Background()) }) + + ctx, span := tp.Tracer("telemetry-test").Start(context.Background(), "op") + t.Cleanup(func() { span.End() }) + return ctx, span.SpanContext().TraceID().String() +} + +func TestTraceIDFromContext(t *testing.T) { + require.Empty(t, TraceIDFromContext(context.Background())) + + ctx, want := recordingContext(t) + require.Equal(t, want, TraceIDFromContext(ctx)) +} + +func TestCarrierFromContext(t *testing.T) { + setPropagator(t) + + require.Nil(t, CarrierFromContext(context.Background())) + + ctx, want := recordingContext(t) + carrier := CarrierFromContext(ctx) + require.NotNil(t, carrier) + require.Contains(t, carrier, "traceparent") + require.Equal(t, want, TraceIDFromCarrier(carrier)) +} + +func TestCarrierFromContextWithoutPropagator(t *testing.T) { + prev := otel.GetTextMapPropagator() + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator()) + t.Cleanup(func() { otel.SetTextMapPropagator(prev) }) + + ctx, _ := recordingContext(t) + require.Nil(t, CarrierFromContext(ctx)) +} + +func TestContextWithCarrier(t *testing.T) { + setPropagator(t) + + base := context.Background() + require.Equal(t, base, ContextWithCarrier(base, nil)) + require.Equal(t, base, ContextWithCarrier(base, map[string]string{})) + + ctx, want := recordingContext(t) + got := TraceIDFromContext(ContextWithCarrier(base, CarrierFromContext(ctx))) + require.Equal(t, want, got) +} + +func TestTraceIDFromCarrier(t *testing.T) { + setPropagator(t) + + require.Empty(t, TraceIDFromCarrier(nil)) + + ctx, want := recordingContext(t) + require.Equal(t, want, TraceIDFromCarrier(CarrierFromContext(ctx))) +} + +func TestTraceIDFromCarrierBytes(t *testing.T) { + setPropagator(t) + + require.Empty(t, TraceIDFromCarrierBytes([]byte("not-json"))) + + ctx, want := recordingContext(t) + data, err := json.Marshal(CarrierFromContext(ctx)) + require.NoError(t, err) + require.Equal(t, want, TraceIDFromCarrierBytes(data)) +} + +func TestClearCarrier(t *testing.T) { + setPropagator(t) + + empty := map[string]string{} + require.Equal(t, empty, ClearCarrier(empty)) + + carrier := map[string]string{ + "Traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + "BAGGAGE": "hello=world", + "tracestate": "vendor=value", + "user-agent": "sigma", + } + require.Equal(t, map[string]string{"user-agent": "sigma"}, ClearCarrier(carrier)) +} + +func TestInjectCarrier(t *testing.T) { + setPropagator(t) + + // Without an active span, stale propagation headers are dropped. + got := InjectCarrier(context.Background(), map[string]string{ + "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + "user-agent": "sigma", + }) + require.Equal(t, map[string]string{"user-agent": "sigma"}, got) + + // Without an active span and a nil carrier, nothing is created. + require.Nil(t, InjectCarrier(context.Background(), nil)) + + // With an active span and a nil carrier, a new carrier is created. + ctx, want := recordingContext(t) + got = InjectCarrier(ctx, nil) + require.Equal(t, want, TraceIDFromCarrier(got)) +} + +func TestInjectCarrierClearsStalePropagationHeaders(t *testing.T) { + setPropagator(t) carrier := map[string]string{ "Traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", diff --git a/pkg/telemetry/trace.go b/pkg/telemetry/trace.go index 0f0a092d..6881f11d 100644 --- a/pkg/telemetry/trace.go +++ b/pkg/telemetry/trace.go @@ -68,7 +68,7 @@ func Init(cfg *config.Configuration) (func(context.Context) error, error) { // 2. Resource (service.name, etc.) res, err := resource.Merge( resource.Default(), - resource.NewWithAttributes(semconv.SchemaURL, + resource.NewSchemaless( semconv.ServiceName(serviceName), ), ) diff --git a/pkg/telemetry/trace_test.go b/pkg/telemetry/trace_test.go new file mode 100644 index 00000000..2b52192f --- /dev/null +++ b/pkg/telemetry/trace_test.go @@ -0,0 +1,98 @@ +// Copyright 2026 sigma +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package telemetry + +import ( + "testing" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + + "github.com/go-sigma/sigma/pkg/config" +) + +func TestInit(t *testing.T) { + prevTP := otel.GetTracerProvider() + prevPropagator := otel.GetTextMapPropagator() + t.Cleanup(func() { + otel.SetTracerProvider(prevTP) + otel.SetTextMapPropagator(prevPropagator) + }) + + tests := []struct { + name string + cfg config.ConfigurationTrace + wantErr bool + wantNil bool + }{ + { + name: "disabled", + cfg: config.ConfigurationTrace{Enabled: false}, + wantNil: true, + }, + { + name: "http", + cfg: config.ConfigurationTrace{Enabled: true, Protocol: "http", Endpoint: "localhost:4318"}, + }, + { + name: "http default protocol", + cfg: config.ConfigurationTrace{Enabled: true, Endpoint: "localhost:4318"}, + }, + { + name: "http insecure with custom service name and ratio", + cfg: config.ConfigurationTrace{ + Enabled: true, + Protocol: "http", + Endpoint: "localhost:4318", + Insecure: true, + SampleRatio: 0.5, + ServiceName: "custom", + }, + }, + { + name: "grpc", + cfg: config.ConfigurationTrace{Enabled: true, Protocol: "grpc", Endpoint: "localhost:4317", Insecure: true}, + }, + { + name: "grpc exporter error", + cfg: config.ConfigurationTrace{Enabled: true, Protocol: "grpc", Endpoint: "\x00", Insecure: true}, + wantErr: true, + wantNil: true, + }, + { + name: "unknown protocol", + cfg: config.ConfigurationTrace{Enabled: true, Protocol: "bogus", Endpoint: "localhost:4317"}, + wantErr: true, + wantNil: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + shutdown, err := Init(&config.Configuration{Trace: tt.cfg}) + if tt.wantErr { + require.Error(t, err) + require.Nil(t, shutdown) + return + } + require.NoError(t, err) + if tt.wantNil { + require.Nil(t, shutdown) + return + } + require.NotNil(t, shutdown) + require.NoError(t, shutdown(t.Context())) + }) + } +}