Repository: getsentry/sentry-go

Issue:
Update DSC `sample-rate` for sentry-go
see https://github.com/getsentry/team-sdks/issues/117

Discussion:

Solution patch:
diff --git a/tracing.go b/tracing.go
index d0b7484..836ba90 100644
--- a/tracing.go
+++ b/tracing.go
@@ -8,6 +8,7 @@ import (
 	"fmt"
 	"net/http"
 	"regexp"
+	"strconv"
 	"strings"
 	"sync"
 	"time"
@@ -72,6 +73,8 @@ type Span struct { //nolint: maligned // prefer readability over optimal memory
 	contexts map[string]Context
 	// a Once instance to make sure that Finish() is only called once.
 	finishOnce sync.Once
+	// explicitSampled is a flag for configuring sampling by using `WithSpanSampled` option.
+	explicitSampled Sampled
 }
 
 // TraceParentContext describes the context of a (remote) parent span.
@@ -302,7 +305,7 @@ func (s *Span) GetTransaction() *Span {
 // func (s *Span) TransactionName() string
 // func (s *Span) SetTransactionName(name string)
 
-// ToSentryTrace returns the seralized TraceParentContext from a transaction/span.
+// ToSentryTrace returns the serialized TraceParentContext from a transaction/span.
 // Use this function to propagate the TraceParentContext to a downstream SDK,
 // either as the value of the "sentry-trace" HTTP header, or as an html "sentry-trace" meta tag.
 func (s *Span) ToSentryTrace() string {
@@ -455,15 +458,15 @@ func (s *Span) sample() Sampled {
 	}
 
 	// #2 explicit sampling decision via StartSpan/StartTransaction options.
-	if s.Sampled != SampledUndefined {
-		Logger.Printf("Using explicit sampling decision from StartSpan/StartTransaction: %v", s.Sampled)
-		switch s.Sampled {
+	if s.explicitSampled != SampledUndefined {
+		Logger.Printf("Using explicit sampling decision from StartSpan/StartTransaction: %v", s.explicitSampled)
+		switch s.explicitSampled {
 		case SampledTrue:
 			s.sampleRate = 1.0
 		case SampledFalse:
 			s.sampleRate = 0.0
 		}
-		return s.Sampled
+		return s.explicitSampled
 	}
 
 	// Variant for non-transaction spans: they inherit the parent decision.
@@ -484,11 +487,15 @@ func (s *Span) sample() Sampled {
 	if sampler != nil {
 		tracesSamplerSampleRate := sampler.Sample(samplingContext)
 		s.sampleRate = tracesSamplerSampleRate
+		// tracesSampler can update the sample_rate on frozen DSC
+		if s.dynamicSamplingContext.HasEntries() {
+			s.dynamicSamplingContext.Entries["sample_rate"] = strconv.FormatFloat(tracesSamplerSampleRate, 'f', -1, 64)
+		}
 		if tracesSamplerSampleRate < 0.0 || tracesSamplerSampleRate > 1.0 {
 			Logger.Printf("Dropping transaction: Returned TracesSampler rate is out of range [0.0, 1.0]: %f", tracesSamplerSampleRate)
 			return SampledFalse
 		}
-		if tracesSamplerSampleRate == 0 {
+		if tracesSamplerSampleRate == 0.0 {
 			Logger.Printf("Dropping transaction: Returned TracesSampler rate is: %f", tracesSamplerSampleRate)
 			return SampledFalse
 		}
@@ -497,25 +504,31 @@ func (s *Span) sample() Sampled {
 			return SampledTrue
 		}
 		Logger.Printf("Dropping transaction: TracesSampler returned rate: %f", tracesSamplerSampleRate)
+
 		return SampledFalse
 	}
+
 	// #4 inherit parent decision.
-	if s.parent != nil {
-		Logger.Printf("Using sampling decision from parent: %v", s.parent.Sampled)
-		switch s.parent.Sampled {
+	if s.Sampled != SampledUndefined {
+		Logger.Printf("Using sampling decision from parent: %v", s.Sampled)
+		switch s.Sampled {
 		case SampledTrue:
 			s.sampleRate = 1.0
 		case SampledFalse:
 			s.sampleRate = 0.0
 		}
-		return s.parent.Sampled
+		return s.Sampled
 	}
 
 	// #5 use TracesSampleRate from ClientOptions.
 	sampleRate := clientOptions.TracesSampleRate
 	s.sampleRate = sampleRate
+	// tracesSampleRate can update the sample_rate on frozen DSC
+	if s.dynamicSamplingContext.HasEntries() {
+		s.dynamicSamplingContext.Entries["sample_rate"] = strconv.FormatFloat(sampleRate, 'f', -1, 64)
+	}
 	if sampleRate < 0.0 || sampleRate > 1.0 {
-		Logger.Printf("Dropping transaction: TracesSamplerRate out of range [0.0, 1.0]: %f", sampleRate)
+		Logger.Printf("Dropping transaction: TracesSampleRate out of range [0.0, 1.0]: %f", sampleRate)
 		return SampledFalse
 	}
 	if sampleRate == 0.0 {
@@ -892,7 +905,7 @@ func WithTransactionSource(source TransactionSource) SpanOption {
 // WithSpanSampled updates the sampling flag for a given span.
 func WithSpanSampled(sampled Sampled) SpanOption {
 	return func(s *Span) {
-		s.Sampled = sampled
+		s.explicitSampled = sampled
 	}
 }
 
@@ -903,7 +916,7 @@ func WithSpanOrigin(origin SpanOrigin) SpanOption {
 	}
 }
 
-// Continue a trace based on traceparent and bagge values.
+// ContinueTrace continues a trace based on traceparent and baggage values.
 // If the SDK is configured with tracing enabled,
 // this function returns populated SpanOption.
 // In any other cases, it populates the propagation context on the scope.



Test patch:
diff --git a/tracing_test.go b/tracing_test.go
index b3dd9fb..ee37633 100644
--- a/tracing_test.go
+++ b/tracing_test.go
@@ -10,6 +10,7 @@ import (
 	"math"
 	"net/http"
 	"reflect"
+	"strings"
 	"sync"
 	"testing"
 	"time"
@@ -678,7 +679,7 @@ func TestSample(t *testing.T) {
 		TracesSampleRate: 0.0,
 	})
 	span = StartSpan(ctx, "op", WithTransactionName("name"), WithSpanSampled(SampledTrue))
-	if got := span.Sampled; got != SampledTrue {
+	if got := span.explicitSampled; got != SampledTrue {
 		t.Fatalf("got %s, want %s", got, SampledTrue)
 	}
 
@@ -716,6 +717,193 @@ func TestSample(t *testing.T) {
 	}
 }
 
+func TestSampleRatePropagation(t *testing.T) {
+	tests := []struct {
+		name                   string
+		clientOptions          ClientOptions
+		traceHeader            string
+		baggageHeader          string
+		expectedRate           float64
+		expectedBaggageEntries []string
+	}{
+		{
+			name: "Tracing disabled",
+			clientOptions: ClientOptions{
+				EnableTracing: false,
+			},
+			traceHeader:            "423d7a0fb16128c8503f067d8447caba-d9246d56c61fc963-1",
+			baggageHeader:          "sentry-trace_id=423d7a0fb16128c8503f067d8447caba,sentry-sampled=true,sentry-sample_rate=1",
+			expectedRate:           0.0,
+			expectedBaggageEntries: nil,
+		},
+		{
+			name: "Inherit from parent - sampled flag = 1",
+			clientOptions: ClientOptions{
+				EnableTracing: true,
+			},
+			traceHeader:   "423d7a0fb16128c8503f067d8447caba-d9246d56c61fc963-1",
+			baggageHeader: "sentry-trace_id=423d7a0fb16128c8503f067d8447caba,sentry-sampled=true,sentry-sample_rate=1",
+			expectedRate:  1.0,
+			expectedBaggageEntries: []string{
+				"sentry-sampled=true",
+				"sentry-trace_id=423d7a0fb16128c8503f067d8447caba",
+				"sentry-sample_rate=1",
+			},
+		},
+		{
+			name: "Inherit from parent - sampled flag = 0",
+			clientOptions: ClientOptions{
+				EnableTracing: true,
+			},
+			traceHeader:   "423d7a0fb16128c8503f067d8447caba-d9246d56c61fc963-0",
+			baggageHeader: "sentry-trace_id=423d7a0fb16128c8503f067d8447caba,sentry-sampled=false,sentry-sample_rate=0.0",
+			expectedRate:  0.0,
+			expectedBaggageEntries: []string{
+				"sentry-sampled=false",
+				"sentry-trace_id=423d7a0fb16128c8503f067d8447caba",
+				"sentry-sample_rate=0",
+			},
+		},
+		{
+			name: "Inherit from parent - defer sampled flag",
+			clientOptions: ClientOptions{
+				EnableTracing: true,
+			},
+			traceHeader:   "423d7a0fb16128c8503f067d8447caba-d9246d56c61fc963",
+			baggageHeader: "sentry-trace_id=423d7a0fb16128c8503f067d8447caba",
+			expectedRate:  0.0,
+			expectedBaggageEntries: []string{
+				"sentry-trace_id=423d7a0fb16128c8503f067d8447caba",
+				"sentry-sample_rate=0",
+			},
+		},
+		{
+			name: "TracesSampler with sampled flag = 1",
+			clientOptions: ClientOptions{
+				EnableTracing: true,
+				TracesSampler: func(_ SamplingContext) float64 {
+					return 0.8
+				},
+			},
+			traceHeader:   "423d7a0fb16128c8503f067d8447caba-d9246d56c61fc963-1",
+			baggageHeader: "sentry-trace_id=423d7a0fb16128c8503f067d8447caba,sentry-sampled=true,sentry-sample_rate=1",
+			expectedRate:  0.8,
+			expectedBaggageEntries: []string{
+				"sentry-sampled=true",
+				"sentry-trace_id=423d7a0fb16128c8503f067d8447caba",
+				"sentry-sample_rate=0.8",
+			},
+		},
+		{
+			name: "TracesSampler with sampled flag = 0",
+			clientOptions: ClientOptions{
+				EnableTracing: true,
+				TracesSampler: func(_ SamplingContext) float64 {
+					return 0.8
+				},
+			},
+			traceHeader:   "423d7a0fb16128c8503f067d8447caba-d9246d56c61fc963-0",
+			baggageHeader: "sentry-trace_id=423d7a0fb16128c8503f067d8447caba,sentry-sampled=false,sentry-sample_rate=0.0",
+			expectedRate:  0.8,
+			expectedBaggageEntries: []string{
+				"sentry-sampled=false",
+				"sentry-trace_id=423d7a0fb16128c8503f067d8447caba",
+				"sentry-sample_rate=0.8",
+			},
+		},
+		{
+			name: "TracesSampler - defer sampled flag",
+			clientOptions: ClientOptions{
+				EnableTracing: true,
+				TracesSampler: func(_ SamplingContext) float64 {
+					return 0.8
+				},
+			},
+			traceHeader:   "423d7a0fb16128c8503f067d8447caba-d9246d56c61fc963",
+			baggageHeader: "sentry-trace_id=423d7a0fb16128c8503f067d8447caba",
+			expectedRate:  0.8,
+			expectedBaggageEntries: []string{
+				"sentry-trace_id=423d7a0fb16128c8503f067d8447caba",
+				"sentry-sample_rate=0.8",
+			},
+		},
+		{
+			name: "TracesSampleRate with sampled flag = 1",
+			clientOptions: ClientOptions{
+				EnableTracing:    true,
+				TracesSampleRate: 0.4,
+			},
+			traceHeader:   "423d7a0fb16128c8503f067d8447caba-d9246d56c61fc963-1",
+			baggageHeader: "sentry-trace_id=423d7a0fb16128c8503f067d8447caba,sentry-sampled=true,sentry-sample_rate=1",
+			expectedRate:  1.0,
+			expectedBaggageEntries: []string{
+				"sentry-sampled=true",
+				"sentry-trace_id=423d7a0fb16128c8503f067d8447caba",
+				"sentry-sample_rate=1",
+			},
+		},
+		{
+			name: "TracesSampleRate with sampled flag = 0",
+			clientOptions: ClientOptions{
+				EnableTracing:    true,
+				TracesSampleRate: 0.4,
+			},
+			traceHeader:   "423d7a0fb16128c8503f067d8447caba-d9246d56c61fc963-0",
+			baggageHeader: "sentry-trace_id=423d7a0fb16128c8503f067d8447caba,sentry-sampled=false,sentry-sample_rate=0.0",
+			expectedRate:  0.0,
+			expectedBaggageEntries: []string{
+				"sentry-sampled=false",
+				"sentry-trace_id=423d7a0fb16128c8503f067d8447caba",
+				"sentry-sample_rate=0",
+			},
+		},
+		{
+			name: "TracesSampleRate - defer sampled flag",
+			clientOptions: ClientOptions{
+				EnableTracing:    true,
+				TracesSampleRate: 0.4,
+			},
+			traceHeader:   "423d7a0fb16128c8503f067d8447caba-d9246d56c61fc963",
+			baggageHeader: "sentry-trace_id=423d7a0fb16128c8503f067d8447caba",
+			expectedRate:  0.4,
+			expectedBaggageEntries: []string{
+				"sentry-trace_id=423d7a0fb16128c8503f067d8447caba",
+				"sentry-sample_rate=0.4",
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			transport := &MockTransport{}
+			ctx := NewTestContext(ClientOptions{
+				EnableTracing:    tt.clientOptions.EnableTracing,
+				TracesSampler:    tt.clientOptions.TracesSampler,
+				TracesSampleRate: tt.clientOptions.TracesSampleRate,
+				Transport:        transport,
+			})
+
+			hub := GetHubFromContext(ctx)
+			options := []SpanOption{
+				ContinueTrace(hub, tt.traceHeader, tt.baggageHeader),
+			}
+			transaction := StartTransaction(ctx, "test-transaction", options...)
+			transaction.Finish()
+
+			baggage := transaction.ToBaggage()
+			for _, header := range tt.expectedBaggageEntries {
+				if !strings.Contains(baggage, header) {
+					t.Errorf("Expected baggage header to contain %q, got %q", header, baggage)
+				}
+			}
+
+			if transaction.sampleRate != tt.expectedRate {
+				t.Errorf("Expected sample rate %f, got %f", tt.expectedRate, transaction.sampleRate)
+			}
+		})
+	}
+}
+
 func TestDoesNotCrashWithEmptyContext(_ *testing.T) {
 	// This test makes sure that we can still start and finish transactions
 	// with empty context (for example, when Sentry SDK is not initialized)