From 6482e20596157eb1d2c84176d46e86dcf1d1f394 Mon Sep 17 00:00:00 2001 From: Yuanyuan Zhao <38882162+yuanyuanzhao3@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:04:20 -0400 Subject: [PATCH 1/8] [samplers/probability] Implements a traceIdRatio sampler that conforms to the threshold based sampling algorithm. Made-with: Cursor --- CHANGELOG.md | 1 + samplers/probability/traceidratio/go.mod | 33 ++++ samplers/probability/traceidratio/go.sum | 44 ++++++ samplers/probability/traceidratio/sampler.go | 129 ++++++++++++++++ .../probability/traceidratio/sampler_test.go | 144 ++++++++++++++++++ .../probability/traceidratio/tracestate.go | 95 ++++++++++++ .../traceidratio/tracestate_test.go | 87 +++++++++++ samplers/probability/traceidratio/version.go | 10 ++ versions.yaml | 1 + 9 files changed, 544 insertions(+) create mode 100644 samplers/probability/traceidratio/go.mod create mode 100644 samplers/probability/traceidratio/go.sum create mode 100644 samplers/probability/traceidratio/sampler.go create mode 100644 samplers/probability/traceidratio/sampler_test.go create mode 100644 samplers/probability/traceidratio/tracestate.go create mode 100644 samplers/probability/traceidratio/tracestate_test.go create mode 100644 samplers/probability/traceidratio/version.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 89017dffdd1..25cc9557590 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added +- Add trace ID ratio sampler in `go.opentelemetry.io/contrib/samplers/probability/traceidratio` that conforms to the threshold-based sampling algorithm. - Configuration file can now be set via `OTEL_CONFIG_FILE` in `go.opentelemetry.io/contrib/otelconf`. (#8639) - Added support for `service` resource detector in `go.opentelemetry.io/contrib/otelconf`. (#8674) diff --git a/samplers/probability/traceidratio/go.mod b/samplers/probability/traceidratio/go.mod new file mode 100644 index 00000000000..0f5b802b285 --- /dev/null +++ b/samplers/probability/traceidratio/go.mod @@ -0,0 +1,33 @@ +module go.opentelemetry.io/contrib/samplers/probability/traceidratio + +go 1.25.0 + +// Replace directives for opentelemetry-go commit with IsRandom/WithRandom (PR #8012). +// Remove when using a released version of opentelemetry-go that includes these APIs. +replace ( + go.opentelemetry.io/otel => go.opentelemetry.io/otel v0.0.0-20260313082256-2ffde5a4289b + go.opentelemetry.io/otel/metric => go.opentelemetry.io/otel/metric v0.0.0-20260313082256-2ffde5a4289b + go.opentelemetry.io/otel/sdk => go.opentelemetry.io/otel/sdk v0.0.0-20260313082256-2ffde5a4289b + go.opentelemetry.io/otel/sdk/metric => go.opentelemetry.io/otel/sdk/metric v0.0.0-20260313082256-2ffde5a4289b + go.opentelemetry.io/otel/trace => go.opentelemetry.io/otel/trace v0.0.0-20260313082256-2ffde5a4289b +) + +require ( + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/otel v1.42.0 + go.opentelemetry.io/otel/sdk v1.42.0 + go.opentelemetry.io/otel/trace v1.42.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + golang.org/x/sys v0.42.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/samplers/probability/traceidratio/go.sum b/samplers/probability/traceidratio/go.sum new file mode 100644 index 00000000000..68ffa74b55c --- /dev/null +++ b/samplers/probability/traceidratio/go.sum @@ -0,0 +1,44 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v0.0.0-20260313082256-2ffde5a4289b h1:9V5tad0Zrnu5A38XtA38tS2i7iIh0QncGaAPDfbeecg= +go.opentelemetry.io/otel v0.0.0-20260313082256-2ffde5a4289b/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/metric v0.0.0-20260313082256-2ffde5a4289b h1:BXYSujuqUpkvYLAuniNgbPfbXXuN4qbdqcfKZ6yjsL4= +go.opentelemetry.io/otel/metric v0.0.0-20260313082256-2ffde5a4289b/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v0.0.0-20260313082256-2ffde5a4289b h1:CyLUNdZpcIlnTLtnE3gHCIlG76C9L6CVMz6r7NUe9NM= +go.opentelemetry.io/otel/sdk v0.0.0-20260313082256-2ffde5a4289b/go.mod h1:xaSffVzFG55EcPDReZb7hNa30cBwL0AMXQgPeGkMPtk= +go.opentelemetry.io/otel/sdk/metric v0.0.0-20260313082256-2ffde5a4289b h1:LwXN9llvuRNj2WiKhrZsYfMjPm68lMrdIYOmdliJ38g= +go.opentelemetry.io/otel/sdk/metric v0.0.0-20260313082256-2ffde5a4289b/go.mod h1:2i8yfZkjLqri5gxkYhyCXv6sOIC/2x0Cne9MXpUMLUE= +go.opentelemetry.io/otel/trace v0.0.0-20260313082256-2ffde5a4289b h1:ABOfwjz5qmdgSN+L6qpdui5JLYvxBm1FmUNz1XkwyY8= +go.opentelemetry.io/otel/trace v0.0.0-20260313082256-2ffde5a4289b/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/samplers/probability/traceidratio/sampler.go b/samplers/probability/traceidratio/sampler.go new file mode 100644 index 00000000000..f540629bbad --- /dev/null +++ b/samplers/probability/traceidratio/sampler.go @@ -0,0 +1,129 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package traceidratio provides a trace ID ratio-based sampler per the +// OpenTelemetry specification. +package traceidratio // import "go.opentelemetry.io/contrib/samplers/probability/traceidratio" + +import ( + "encoding/binary" + "fmt" + "math" + "strconv" + "strings" + + "go.opentelemetry.io/otel" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/trace" +) + +const ( + // DefaultSamplingPrecision is the default precision for threshold encoding. + DefaultSamplingPrecision = 4 + maxAdjustedCount = 1 << 56 + // randomnessMask masks the least significant 56 bits of the trace ID per + // W3C Trace Context Level 2 Random Trace ID Flag. + // https://www.w3.org/TR/trace-context-2/#random-trace-id-flag + randomnessMask = maxAdjustedCount - 1 + + probabilityZeroThreshold = 1 / float64(maxAdjustedCount) + probabilityOneThreshold = 1 - 0x1p-52 +) + +// TraceIDRatioSampler is a sampler that samples a fraction of traces based on +// the trace ID. It is exported for testing (e.g., to assert threshold values). +type TraceIDRatioSampler struct { + threshold uint64 + thkv string + description string +} + +// Threshold returns the rejection threshold for testing. +func (ts *TraceIDRatioSampler) Threshold() uint64 { + return ts.threshold +} + +// ShouldSample implements sdktrace.Sampler. +func (ts *TraceIDRatioSampler) ShouldSample(p sdktrace.SamplingParameters) sdktrace.SamplingResult { + psc := trace.SpanContextFromContext(p.ParentContext) + state := psc.TraceState() + + existingOtts := state.Get("ot") + + var randomness uint64 + var hasRandomness bool + if existingOtts != "" { + randomness, hasRandomness = tracestateRandomness(existingOtts) + } + + if !hasRandomness { + randomness = binary.BigEndian.Uint64(p.TraceID[8:16]) & randomnessMask + } + + if ts.threshold > randomness { + return sdktrace.SamplingResult{ + Decision: sdktrace.Drop, + Tracestate: state, + } + } + + var newOtts string + if !psc.TraceFlags().IsRandom() { + newOtts = eraseTraceStateThKeyValue(existingOtts) + } else { + newOtts = InsertOrUpdateTraceStateThKeyValue(existingOtts, ts.thkv) + } + + if combined, err := state.Insert("ot", newOtts); err != nil { + otel.Handle(fmt.Errorf("could not combine tracestate: %w", err)) + return sdktrace.SamplingResult{Decision: sdktrace.Drop, Tracestate: state} + } else { + state = combined + } + return sdktrace.SamplingResult{Decision: sdktrace.RecordAndSample, Tracestate: state} +} + +// Description implements sdktrace.Sampler. +func (ts *TraceIDRatioSampler) Description() string { + return ts.description +} + +// TraceIDRatioBased samples a given fraction of traces. Fractions >= 1 will +// always sample. Fractions < 0 are treated as zero. To respect the parent +// trace's SampledFlag, the TraceIDRatioBased sampler should be used as a +// delegate of a Parent sampler. +func TraceIDRatioBased(fraction float64) sdktrace.Sampler { + const ( + maxp = 14 + defp = DefaultSamplingPrecision + hbits = 4 + ) + if fraction > probabilityOneThreshold { + return sdktrace.AlwaysSample() + } + if fraction < probabilityZeroThreshold { + return sdktrace.NeverSample() + } + + _, expF := math.Frexp(fraction) + _, expR := math.Frexp(1 - fraction) + precision := min(maxp, max(defp+expF/-hbits, defp+expR/-hbits)) + + scaled := uint64(math.Round(fraction * float64(maxAdjustedCount))) + threshold := maxAdjustedCount - scaled + + if shift := hbits * (maxp - precision); shift != 0 { + half := uint64(1) << (shift - 1) + threshold += half + threshold >>= shift + threshold <<= shift + } + + tvalue := strings.TrimRight(strconv.FormatUint(maxAdjustedCount+threshold, 16)[1:], "0") + return &TraceIDRatioSampler{ + threshold: threshold, + thkv: "th:" + tvalue, + description: fmt.Sprintf("TraceIDRatioBased{%g}", fraction), + } +} + diff --git a/samplers/probability/traceidratio/sampler_test.go b/samplers/probability/traceidratio/sampler_test.go new file mode 100644 index 00000000000..95f4f58ac47 --- /dev/null +++ b/samplers/probability/traceidratio/sampler_test.go @@ -0,0 +1,144 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package traceidratio + +import ( + "context" + "math/rand" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/trace" +) + +func TestTraceIDRatioBased(t *testing.T) { + t.Run("description", func(t *testing.T) { + for _, tc := range []struct { + prob float64 + desc string + }{ + {0.5, "TraceIDRatioBased{0.5}"}, + {1. / 3, "TraceIDRatioBased{0.3333333333333333}"}, + {1. / 10000, "TraceIDRatioBased{0.0001}"}, + {1, "AlwaysOnSampler"}, + {1.5, "AlwaysOnSampler"}, + {0, "AlwaysOffSampler"}, + {-0.5, "AlwaysOffSampler"}, + } { + require.Equal(t, tc.desc, TraceIDRatioBased(tc.prob).Description()) + } + }) + + t.Run("threshold", func(t *testing.T) { + for _, tc := range []struct { + prob float64 + threshold uint64 + }{ + {0.5, 0x80000000000000}, + {1 / 3.0, 0xaaab0000000000}, + {2 / 3.0, 0x55550000000000}, + {1 / 10.0, 0xe6660000000000}, + {1 / 256.0, 0xff000000000000}, + {1 / 65536.0, 0xffff0000000000}, + {1 / 1048576.0, 0xfffff000000000}, + } { + sampler := TraceIDRatioBased(tc.prob).(*TraceIDRatioSampler) + require.Equal(t, tc.threshold, sampler.Threshold()) + } + }) + + t.Run("inclusive sampling", func(t *testing.T) { + const numSamplers = 100 + const numTraces = 50 + for range numSamplers { + ratioLo, ratioHi := rand.Float64(), rand.Float64() + if ratioHi < ratioLo { + ratioLo, ratioHi = ratioHi, ratioLo + } + samplerHi := TraceIDRatioBased(ratioHi) + samplerLo := TraceIDRatioBased(ratioLo) + for range numTraces { + traceID := trace.TraceID{} + rand.Read(traceID[:]) + params := sdktrace.SamplingParameters{ + ParentContext: trace.ContextWithSpanContext( + context.Background(), + trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: traceID, + TraceFlags: trace.FlagsRandom, + }), + ), + TraceID: traceID, + } + if samplerLo.ShouldSample(params).Decision == sdktrace.RecordAndSample { + assert.Equal(t, sdktrace.RecordAndSample, samplerHi.ShouldSample(params).Decision, + "%s sampled but %s did not", samplerLo.Description(), samplerHi.Description()) + } + } + } + }) + + t.Run("RecordAndSample adds ot.th to tracestate", func(t *testing.T) { + const traceIDWillSample = "00000000000000000080000000000000" + sampler := TraceIDRatioBased(0.5) + traceID, _ := trace.TraceIDFromHex(traceIDWillSample) + spanID, _ := trace.SpanIDFromHex("00f067aa0ba902b7") + initialState, err := trace.ParseTraceState("vendor=value") + require.NoError(t, err) + + parentCtx := trace.ContextWithSpanContext( + context.Background(), + trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: traceID, + SpanID: spanID, + TraceFlags: trace.FlagsRandom, + TraceState: initialState, + }), + ) + params := sdktrace.SamplingParameters{ + ParentContext: parentCtx, + TraceID: traceID, + } + + result := sampler.ShouldSample(params) + + assert.Equal(t, sdktrace.RecordAndSample, result.Decision) + ot := result.Tracestate.Get("ot") + require.NotEmpty(t, ot) + assert.True(t, strings.HasPrefix(ot, "th:"), "ot value should contain th key, got %q", ot) + assert.Equal(t, "value", result.Tracestate.Get("vendor")) + }) + + t.Run("Drop when randomness < threshold", func(t *testing.T) { + const traceIDWillDrop = "0000000000000000007fffffffffffff" + sampler := TraceIDRatioBased(0.5) + traceID, _ := trace.TraceIDFromHex(traceIDWillDrop) + spanID, _ := trace.SpanIDFromHex("00f067aa0ba902b7") + initialState, err := trace.ParseTraceState("ot=th:0;rv:0123456789abcd,vendor=value") + require.NoError(t, err) + + parentCtx := trace.ContextWithSpanContext( + context.Background(), + trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: traceID, + SpanID: spanID, + TraceFlags: trace.FlagsRandom, + TraceState: initialState, + }), + ) + params := sdktrace.SamplingParameters{ + ParentContext: parentCtx, + TraceID: traceID, + } + + result := sampler.ShouldSample(params) + + assert.Equal(t, sdktrace.Drop, result.Decision) + assert.Equal(t, initialState, result.Tracestate) + }) +} diff --git a/samplers/probability/traceidratio/tracestate.go b/samplers/probability/traceidratio/tracestate.go new file mode 100644 index 00000000000..1c99d33b974 --- /dev/null +++ b/samplers/probability/traceidratio/tracestate.go @@ -0,0 +1,95 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package traceidratio // import "go.opentelemetry.io/contrib/samplers/probability/traceidratio" + +import ( + "fmt" + "strconv" + "strings" + + "go.opentelemetry.io/otel" +) + +// InsertOrUpdateTraceStateThKeyValue inserts or updates the threshold (th) key-value +// in the OTel tracestate "ot" field. It is exported for use by samplers that need +// to set the threshold (e.g., AlwaysOn sampler setting th:0). +func InsertOrUpdateTraceStateThKeyValue(existingOtts, thkv string) string { + if existingOtts == "" { + return thkv + } + + start := -1 + end := -1 + if strings.HasPrefix(existingOtts, "th:") { + start = 0 + } else if idx := strings.Index(existingOtts, ";th:"); idx != -1 { + start = idx + 1 + } + if start == -1 { + return thkv + ";" + existingOtts + } + + for end = start; end < len(existingOtts); end++ { + if existingOtts[end] == ';' { + end++ + break + } + } + + if end == len(existingOtts) { + return strings.TrimSuffix(thkv+";"+existingOtts[0:start], ";") + } + return thkv + ";" + existingOtts[0:start] + existingOtts[end:] +} + +// tracestateRandomness determines whether there is a randomness "rv" sub-key in +// otts (the top-level OTel tracestate field). If present, "rv" is a 56-bit +// unsigned integer, encoded in 14 hex digits. +func tracestateRandomness(otts string) (randomness uint64, hasRandomness bool) { + var start int + if strings.HasPrefix(otts, "rv:") { + start = 3 + } else if idx := strings.Index(otts, ";rv:"); idx != -1 { + start = idx + 4 + } else { + return 0, false + } + + if len(otts) < start+14 || (len(otts) > start+14 && otts[start+14] != ';') { + otel.Handle(fmt.Errorf("could not parse tracestate randomness: %s", otts)) + return 0, false + } + + if rv, err := strconv.ParseUint(otts[start:start+14], 16, 56); err != nil { + otel.Handle(fmt.Errorf("could not parse tracestate randomness: %s", otts)) + return 0, false + } else { + randomness = rv + hasRandomness = true + } + return +} + +func eraseTraceStateThKeyValue(otts string) string { + start := strings.Index(otts, "th:") + if start == -1 { + return otts + } + if start > 0 && otts[start-1] == ';' { + start-- + } + end := -1 + for end = start + 1; end < len(otts); end++ { + if otts[end] == ';' { + if start == 0 { + end++ + } + break + } + } + if end == len(otts) { + return otts[0:start] + } + return otts[0:start] + otts[end:] +} diff --git a/samplers/probability/traceidratio/tracestate_test.go b/samplers/probability/traceidratio/tracestate_test.go new file mode 100644 index 00000000000..b328ef9dad8 --- /dev/null +++ b/samplers/probability/traceidratio/tracestate_test.go @@ -0,0 +1,87 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package traceidratio + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTracestateRandomness(t *testing.T) { + const validRV = "0123456789abcd" + const validRVValue uint64 = 0x0123456789abcd + + testCases := []struct { + name string + otts string + wantRandom uint64 + wantHasRV bool + }{ + {"rv at beginning", "rv:" + validRV, validRVValue, true}, + {"rv at beginning with more keys", "rv:" + validRV + ";th:0;other:value", validRVValue, true}, + {"rv in middle", "th:0;rv:" + validRV + ";other:value", validRVValue, true}, + {"rv at end", "th:0;other:value;rv:" + validRV, validRVValue, true}, + {"rv with max 56-bit value", "rv:0fffffffffffff", 0x0fffffffffffff, true}, + {"no rv key", "th:0;other:value", 0, false}, + {"empty string", "", 0, false}, + {"rv value too short", "rv:0123456789abc", 0, false}, + {"rv value too long", "rv:0123456789abcdef", 0, false}, + {"rv with invalid hex", "rv:0123456789abcg", 0, false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + gotRandom, gotHasRV := tracestateRandomness(tc.otts) + assert.Equal(t, tc.wantHasRV, gotHasRV) + if tc.wantHasRV { + assert.Equal(t, tc.wantRandom, gotRandom) + } + }) + } +} + +func TestEraseTraceStateThKeyValue(t *testing.T) { + testCases := []struct { + name string + otts string + want string + }{ + {"empty string", "", ""}, + {"no th in existing", "rv:0123456789abcd;other:value", "rv:0123456789abcd;other:value"}, + {"only th returns empty", "th:0ad", ""}, + {"th at front", "th:0ad;rv:0123456789abcd", "rv:0123456789abcd"}, + {"th in middle", "rv:0123456789abcd;th:0ad;other:value", "rv:0123456789abcd;other:value"}, + {"th at end", "rv:0123456789abcd;th:0ad", "rv:0123456789abcd"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := eraseTraceStateThKeyValue(tc.otts) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestInsertOrUpdateTraceStateThKeyValue(t *testing.T) { + testCases := []struct { + name string + existingOtts string + thkv string + want string + }{ + {"empty existing adds th at front", "", "th:123456789abcd", "th:123456789abcd"}, + {"no th in existing adds th at front", "rv:0123456789abcd;other:value", "th:fedcba987654321", "th:fedcba987654321;rv:0123456789abcd;other:value"}, + {"existing th is replaced", "rv:0123456789abcd;th:0ad;other:value", "th:0e1", "th:0e1;rv:0123456789abcd;other:value"}, + {"th at front is replaced", "th:0ad;rv:0123456789abcd", "th:0e1", "th:0e1;rv:0123456789abcd"}, + {"only th in existing is replaced", "th:0ad", "th:0e1", "th:0e1"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := InsertOrUpdateTraceStateThKeyValue(tc.existingOtts, tc.thkv) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/samplers/probability/traceidratio/version.go b/samplers/probability/traceidratio/version.go new file mode 100644 index 00000000000..e55afbd0697 --- /dev/null +++ b/samplers/probability/traceidratio/version.go @@ -0,0 +1,10 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package traceidratio // import "go.opentelemetry.io/contrib/samplers/probability/traceidratio" + +// Version is the current release version of the trace ID ratio sampler. +func Version() string { + return "0.24.0" + // This string is updated by the pre_release.sh script during release +} diff --git a/versions.yaml b/versions.yaml index 8981d96676b..2a12b76846a 100644 --- a/versions.yaml +++ b/versions.yaml @@ -56,6 +56,7 @@ module-sets: - go.opentelemetry.io/contrib/samplers/jaegerremote - go.opentelemetry.io/contrib/samplers/jaegerremote/example - go.opentelemetry.io/contrib/samplers/probability/consistent + - go.opentelemetry.io/contrib/samplers/probability/traceidratio experimental-config: version: v0.22.0 modules: From efc52f554b47571a86d56bd215b99d45978f5069 Mon Sep 17 00:00:00 2001 From: Yuanyuan Zhao <38882162+yuanyuanzhao3@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:30:40 -0400 Subject: [PATCH 2/8] [samplers/probability] Adds PR number for change log entry for traceIdRatio based sampler. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25cc9557590..080de1c9ab1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added -- Add trace ID ratio sampler in `go.opentelemetry.io/contrib/samplers/probability/traceidratio` that conforms to the threshold-based sampling algorithm. +- Add trace ID ratio sampler in `go.opentelemetry.io/contrib/samplers/probability/traceidratio` that conforms to the threshold-based sampling algorithm. (#8714) - Configuration file can now be set via `OTEL_CONFIG_FILE` in `go.opentelemetry.io/contrib/otelconf`. (#8639) - Added support for `service` resource detector in `go.opentelemetry.io/contrib/otelconf`. (#8674) From 6471758c50fa5861f75aa48f2006fffa8702d12a Mon Sep 17 00:00:00 2001 From: Yuanyuan Zhao <38882162+yuanyuanzhao3@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:54:11 -0400 Subject: [PATCH 3/8] [samplers/probability] TraceIdRatioBased: addresses lints. --- samplers/probability/traceidratio/sampler.go | 21 +++++++++---------- .../probability/traceidratio/sampler_test.go | 9 ++++---- .../probability/traceidratio/tracestate.go | 10 ++++----- 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/samplers/probability/traceidratio/sampler.go b/samplers/probability/traceidratio/sampler.go index f540629bbad..d2f925d77a8 100644 --- a/samplers/probability/traceidratio/sampler.go +++ b/samplers/probability/traceidratio/sampler.go @@ -20,7 +20,7 @@ import ( const ( // DefaultSamplingPrecision is the default precision for threshold encoding. DefaultSamplingPrecision = 4 - maxAdjustedCount = 1 << 56 + maxAdjustedCount = 1 << 56 // randomnessMask masks the least significant 56 bits of the trace ID per // W3C Trace Context Level 2 Random Trace ID Flag. // https://www.w3.org/TR/trace-context-2/#random-trace-id-flag @@ -30,21 +30,21 @@ const ( probabilityOneThreshold = 1 - 0x1p-52 ) -// TraceIDRatioSampler is a sampler that samples a fraction of traces based on +// Sampler is a sampler that samples a fraction of traces based on // the trace ID. It is exported for testing (e.g., to assert threshold values). -type TraceIDRatioSampler struct { +type Sampler struct { threshold uint64 thkv string description string } // Threshold returns the rejection threshold for testing. -func (ts *TraceIDRatioSampler) Threshold() uint64 { +func (ts *Sampler) Threshold() uint64 { return ts.threshold } // ShouldSample implements sdktrace.Sampler. -func (ts *TraceIDRatioSampler) ShouldSample(p sdktrace.SamplingParameters) sdktrace.SamplingResult { +func (ts *Sampler) ShouldSample(p sdktrace.SamplingParameters) sdktrace.SamplingResult { psc := trace.SpanContextFromContext(p.ParentContext) state := psc.TraceState() @@ -74,17 +74,17 @@ func (ts *TraceIDRatioSampler) ShouldSample(p sdktrace.SamplingParameters) sdktr newOtts = InsertOrUpdateTraceStateThKeyValue(existingOtts, ts.thkv) } - if combined, err := state.Insert("ot", newOtts); err != nil { + combined, err := state.Insert("ot", newOtts) + if err != nil { otel.Handle(fmt.Errorf("could not combine tracestate: %w", err)) return sdktrace.SamplingResult{Decision: sdktrace.Drop, Tracestate: state} - } else { - state = combined } + state = combined return sdktrace.SamplingResult{Decision: sdktrace.RecordAndSample, Tracestate: state} } // Description implements sdktrace.Sampler. -func (ts *TraceIDRatioSampler) Description() string { +func (ts *Sampler) Description() string { return ts.description } @@ -120,10 +120,9 @@ func TraceIDRatioBased(fraction float64) sdktrace.Sampler { } tvalue := strings.TrimRight(strconv.FormatUint(maxAdjustedCount+threshold, 16)[1:], "0") - return &TraceIDRatioSampler{ + return &Sampler{ threshold: threshold, thkv: "th:" + tvalue, description: fmt.Sprintf("TraceIDRatioBased{%g}", fraction), } } - diff --git a/samplers/probability/traceidratio/sampler_test.go b/samplers/probability/traceidratio/sampler_test.go index 95f4f58ac47..117593db963 100644 --- a/samplers/probability/traceidratio/sampler_test.go +++ b/samplers/probability/traceidratio/sampler_test.go @@ -4,7 +4,6 @@ package traceidratio import ( - "context" "math/rand" "strings" "testing" @@ -47,7 +46,7 @@ func TestTraceIDRatioBased(t *testing.T) { {1 / 65536.0, 0xffff0000000000}, {1 / 1048576.0, 0xfffff000000000}, } { - sampler := TraceIDRatioBased(tc.prob).(*TraceIDRatioSampler) + sampler := TraceIDRatioBased(tc.prob).(*Sampler) require.Equal(t, tc.threshold, sampler.Threshold()) } }) @@ -67,7 +66,7 @@ func TestTraceIDRatioBased(t *testing.T) { rand.Read(traceID[:]) params := sdktrace.SamplingParameters{ ParentContext: trace.ContextWithSpanContext( - context.Background(), + t.Context(), trace.NewSpanContext(trace.SpanContextConfig{ TraceID: traceID, TraceFlags: trace.FlagsRandom, @@ -92,7 +91,7 @@ func TestTraceIDRatioBased(t *testing.T) { require.NoError(t, err) parentCtx := trace.ContextWithSpanContext( - context.Background(), + t.Context(), trace.NewSpanContext(trace.SpanContextConfig{ TraceID: traceID, SpanID: spanID, @@ -123,7 +122,7 @@ func TestTraceIDRatioBased(t *testing.T) { require.NoError(t, err) parentCtx := trace.ContextWithSpanContext( - context.Background(), + t.Context(), trace.NewSpanContext(trace.SpanContextConfig{ TraceID: traceID, SpanID: spanID, diff --git a/samplers/probability/traceidratio/tracestate.go b/samplers/probability/traceidratio/tracestate.go index 1c99d33b974..93620e752f6 100644 --- a/samplers/probability/traceidratio/tracestate.go +++ b/samplers/probability/traceidratio/tracestate.go @@ -61,13 +61,13 @@ func tracestateRandomness(otts string) (randomness uint64, hasRandomness bool) { return 0, false } - if rv, err := strconv.ParseUint(otts[start:start+14], 16, 56); err != nil { + rv, err := strconv.ParseUint(otts[start:start+14], 16, 56) + if err != nil { otel.Handle(fmt.Errorf("could not parse tracestate randomness: %s", otts)) return 0, false - } else { - randomness = rv - hasRandomness = true } + randomness = rv + hasRandomness = true return } @@ -79,7 +79,7 @@ func eraseTraceStateThKeyValue(otts string) string { if start > 0 && otts[start-1] == ';' { start-- } - end := -1 + var end int for end = start + 1; end < len(otts); end++ { if otts[end] == ';' { if start == 0 { From f363410a8353d6b1e96ee8367f7a6eb39dce7d51 Mon Sep 17 00:00:00 2001 From: Yuanyuan Zhao <38882162+yuanyuanzhao3@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:22:00 -0400 Subject: [PATCH 4/8] [samplers/probability] TraceIdRatioBased: adding testcase where no randomness (explicit or from traceid with random tracestate flag) exists. --- samplers/probability/traceidratio/sampler.go | 10 ++- .../probability/traceidratio/sampler_test.go | 68 +++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/samplers/probability/traceidratio/sampler.go b/samplers/probability/traceidratio/sampler.go index d2f925d77a8..180ec736992 100644 --- a/samplers/probability/traceidratio/sampler.go +++ b/samplers/probability/traceidratio/sampler.go @@ -68,14 +68,18 @@ func (ts *Sampler) ShouldSample(p sdktrace.SamplingParameters) sdktrace.Sampling } var newOtts string - if !psc.TraceFlags().IsRandom() { - newOtts = eraseTraceStateThKeyValue(existingOtts) - } else { + // Only when the randomness we extracted (either from explicit rv value or from trace ID) is present, + // can we insert or update the th key-value. Otherwise, we should erase any existing `th` key-value + // to signal that the span is not guaranteed to be statistically representative of the trace. + if hasRandomness || psc.TraceFlags().IsRandom() { newOtts = InsertOrUpdateTraceStateThKeyValue(existingOtts, ts.thkv) + } else { + newOtts = eraseTraceStateThKeyValue(existingOtts) } combined, err := state.Insert("ot", newOtts) if err != nil { + // TODO: think about how this should be handled. otel.Handle(fmt.Errorf("could not combine tracestate: %w", err)) return sdktrace.SamplingResult{Decision: sdktrace.Drop, Tracestate: state} } diff --git a/samplers/probability/traceidratio/sampler_test.go b/samplers/probability/traceidratio/sampler_test.go index 117593db963..6e0150a48ea 100644 --- a/samplers/probability/traceidratio/sampler_test.go +++ b/samplers/probability/traceidratio/sampler_test.go @@ -4,6 +4,7 @@ package traceidratio import ( + "context" "math/rand" "strings" "testing" @@ -113,6 +114,73 @@ func TestTraceIDRatioBased(t *testing.T) { assert.Equal(t, "value", result.Tracestate.Get("vendor")) }) + t.Run("RecordAndSample with explicit rv and no randomness flag inserts th in tracestate", func(t *testing.T) { + // No randomness flag, but explicit rv in tracestate: use rv for sampling and insert th. + // Use a trace ID with low randomness so we'd Drop without rv - proves we use rv. + sampler := TraceIDRatioBased(0.5) + traceID, _ := trace.TraceIDFromHex("00000000000000000000000000000001") // trace ID randomness would Drop + spanID, _ := trace.SpanIDFromHex("00f067aa0ba902b7") + // rv >= threshold (0x80000000000000) so we RecordAndSample + initialState, err := trace.ParseTraceState("ot=rv:80000000000000,vendor=value") + require.NoError(t, err) + + parentCtx := trace.ContextWithSpanContext( + context.Background(), + trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: traceID, + SpanID: spanID, + TraceFlags: trace.TraceFlags(0), // No randomness flag + TraceState: initialState, + }), + ) + params := sdktrace.SamplingParameters{ + ParentContext: parentCtx, + TraceID: traceID, + } + + result := sampler.ShouldSample(params) + + assert.Equal(t, sdktrace.RecordAndSample, result.Decision, "rv value should be used for sampling decision") + ot := result.Tracestate.Get("ot") + require.NotEmpty(t, ot) + assert.True(t, strings.Contains(ot, "th:"), "ot value should contain th when rv is present, got %q", ot) + assert.Equal(t, "value", result.Tracestate.Get("vendor")) + }) + + t.Run("RecordAndSample without randomness flag erases ot.th from tracestate", func(t *testing.T) { + // No rv in tracestate and no randomness flag in TraceFlags - both must be false to erase th. + // Use trace ID with randomness >= threshold so we pass the threshold check. + sampler := TraceIDRatioBased(0.5) + traceID, _ := trace.TraceIDFromHex("00000000000000000080000000000000") + spanID, _ := trace.SpanIDFromHex("00f067aa0ba902b7") + // Initial state has th and other ot keys but no rv - randomness comes from trace ID. + // After erasing th, "other:value" remains (tracestate requires non-empty ot value). + initialState, err := trace.ParseTraceState("ot=th:0ad;other:value,vendor=v") + require.NoError(t, err) + + parentCtx := trace.ContextWithSpanContext( + context.Background(), + trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: traceID, + SpanID: spanID, + TraceFlags: trace.TraceFlags(0), // No randomness flag - IsRandom() returns false + TraceState: initialState, + }), + ) + params := sdktrace.SamplingParameters{ + ParentContext: parentCtx, + TraceID: traceID, + } + + result := sampler.ShouldSample(params) + + assert.Equal(t, sdktrace.RecordAndSample, result.Decision) + ot := result.Tracestate.Get("ot") + // When neither hasRandomness nor TraceFlags.IsRandom(), th is erased + assert.False(t, strings.Contains(ot, "th:"), "ot value should not contain th when TraceFlags has no randomness flag and no rv in tracestate, got %q", ot) + assert.Equal(t, "v", result.Tracestate.Get("vendor")) + }) + t.Run("Drop when randomness < threshold", func(t *testing.T) { const traceIDWillDrop = "0000000000000000007fffffffffffff" sampler := TraceIDRatioBased(0.5) From f4b605327831e2fc053eef2d073546119d462612 Mon Sep 17 00:00:00 2001 From: Yuanyuan Zhao <38882162+yuanyuanzhao3@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:42:25 -0400 Subject: [PATCH 5/8] [sampler/probability] TraceIdRatioBased: adding test where erasing `th` key resulting in an empty otts. This happens when `th` was the only subkey in `ot` in the incoming tracestate. However, the incoming traceid or tracestate lacks randomness, so we end up erasing `th`. --- samplers/probability/traceidratio/sampler.go | 10 +++++-- .../probability/traceidratio/sampler_test.go | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/samplers/probability/traceidratio/sampler.go b/samplers/probability/traceidratio/sampler.go index 180ec736992..aec1bc137f9 100644 --- a/samplers/probability/traceidratio/sampler.go +++ b/samplers/probability/traceidratio/sampler.go @@ -77,14 +77,18 @@ func (ts *Sampler) ShouldSample(p sdktrace.SamplingParameters) sdktrace.Sampling newOtts = eraseTraceStateThKeyValue(existingOtts) } + if newOtts == "" { + state = state.Delete("ot") + return sdktrace.SamplingResult{Decision: sdktrace.RecordAndSample, Tracestate: state} + } combined, err := state.Insert("ot", newOtts) if err != nil { - // TODO: think about how this should be handled. + // This in practice should never happen because `ot` is a valid key and any new value we + // create for it is an update to `th` and should always be valid. otel.Handle(fmt.Errorf("could not combine tracestate: %w", err)) return sdktrace.SamplingResult{Decision: sdktrace.Drop, Tracestate: state} } - state = combined - return sdktrace.SamplingResult{Decision: sdktrace.RecordAndSample, Tracestate: state} + return sdktrace.SamplingResult{Decision: sdktrace.RecordAndSample, Tracestate: combined} } // Description implements sdktrace.Sampler. diff --git a/samplers/probability/traceidratio/sampler_test.go b/samplers/probability/traceidratio/sampler_test.go index 6e0150a48ea..b32d0029ac0 100644 --- a/samplers/probability/traceidratio/sampler_test.go +++ b/samplers/probability/traceidratio/sampler_test.go @@ -181,6 +181,35 @@ func TestTraceIDRatioBased(t *testing.T) { assert.Equal(t, "v", result.Tracestate.Get("vendor")) }) + t.Run("RecordAndSample when ot becomes empty deletes ot from tracestate", func(t *testing.T) { + // Erasing th yields empty string; delete ot from tracestate instead of inserting. + sampler := TraceIDRatioBased(0.5) + traceID, _ := trace.TraceIDFromHex("00000000000000000080000000000000") + spanID, _ := trace.SpanIDFromHex("00f067aa0ba902b7") + initialState, err := trace.ParseTraceState("ot=th:0ad,vendor=value") + require.NoError(t, err) + + parentCtx := trace.ContextWithSpanContext( + context.Background(), + trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: traceID, + SpanID: spanID, + TraceFlags: trace.TraceFlags(0), + TraceState: initialState, + }), + ) + params := sdktrace.SamplingParameters{ + ParentContext: parentCtx, + TraceID: traceID, + } + + result := sampler.ShouldSample(params) + + assert.Equal(t, sdktrace.RecordAndSample, result.Decision) + assert.Empty(t, result.Tracestate.Get("ot")) + assert.Equal(t, "value", result.Tracestate.Get("vendor")) + }) + t.Run("Drop when randomness < threshold", func(t *testing.T) { const traceIDWillDrop = "0000000000000000007fffffffffffff" sampler := TraceIDRatioBased(0.5) From 7be955094854638ca729f1b3e91353ba13eb9c1f Mon Sep 17 00:00:00 2001 From: Yuanyuan Zhao <38882162+yuanyuanzhao3@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:41:33 -0400 Subject: [PATCH 6/8] [sampler/probability] traceidratio sampler: adding a few more test cases and fixing lints. --- samplers/probability/traceidratio/sampler.go | 2 + .../probability/traceidratio/sampler_test.go | 139 ++++++++++++++++-- .../probability/traceidratio/tracestate.go | 4 +- 3 files changed, 134 insertions(+), 11 deletions(-) diff --git a/samplers/probability/traceidratio/sampler.go b/samplers/probability/traceidratio/sampler.go index aec1bc137f9..eb9146bfd8f 100644 --- a/samplers/probability/traceidratio/sampler.go +++ b/samplers/probability/traceidratio/sampler.go @@ -100,6 +100,8 @@ func (ts *Sampler) Description() string { // always sample. Fractions < 0 are treated as zero. To respect the parent // trace's SampledFlag, the TraceIDRatioBased sampler should be used as a // delegate of a Parent sampler. +// +//nolint:revive // TraceIDRatioBased is the standard OpenTelemetry sampler name func TraceIDRatioBased(fraction float64) sdktrace.Sampler { const ( maxp = 14 diff --git a/samplers/probability/traceidratio/sampler_test.go b/samplers/probability/traceidratio/sampler_test.go index b32d0029ac0..9fffb4c404d 100644 --- a/samplers/probability/traceidratio/sampler_test.go +++ b/samplers/probability/traceidratio/sampler_test.go @@ -4,8 +4,8 @@ package traceidratio import ( - "context" - "math/rand" + "crypto/rand" + mrand "math/rand" "strings" "testing" @@ -56,7 +56,7 @@ func TestTraceIDRatioBased(t *testing.T) { const numSamplers = 100 const numTraces = 50 for range numSamplers { - ratioLo, ratioHi := rand.Float64(), rand.Float64() + ratioLo, ratioHi := mrand.Float64(), mrand.Float64() if ratioHi < ratioLo { ratioLo, ratioHi = ratioHi, ratioLo } @@ -64,7 +64,7 @@ func TestTraceIDRatioBased(t *testing.T) { samplerLo := TraceIDRatioBased(ratioLo) for range numTraces { traceID := trace.TraceID{} - rand.Read(traceID[:]) + _, _ = rand.Read(traceID[:]) params := sdktrace.SamplingParameters{ ParentContext: trace.ContextWithSpanContext( t.Context(), @@ -125,7 +125,7 @@ func TestTraceIDRatioBased(t *testing.T) { require.NoError(t, err) parentCtx := trace.ContextWithSpanContext( - context.Background(), + t.Context(), trace.NewSpanContext(trace.SpanContextConfig{ TraceID: traceID, SpanID: spanID, @@ -143,7 +143,7 @@ func TestTraceIDRatioBased(t *testing.T) { assert.Equal(t, sdktrace.RecordAndSample, result.Decision, "rv value should be used for sampling decision") ot := result.Tracestate.Get("ot") require.NotEmpty(t, ot) - assert.True(t, strings.Contains(ot, "th:"), "ot value should contain th when rv is present, got %q", ot) + assert.Contains(t, ot, "th:", "ot value should contain th when rv is present, got %q", ot) assert.Equal(t, "value", result.Tracestate.Get("vendor")) }) @@ -159,7 +159,7 @@ func TestTraceIDRatioBased(t *testing.T) { require.NoError(t, err) parentCtx := trace.ContextWithSpanContext( - context.Background(), + t.Context(), trace.NewSpanContext(trace.SpanContextConfig{ TraceID: traceID, SpanID: spanID, @@ -177,7 +177,7 @@ func TestTraceIDRatioBased(t *testing.T) { assert.Equal(t, sdktrace.RecordAndSample, result.Decision) ot := result.Tracestate.Get("ot") // When neither hasRandomness nor TraceFlags.IsRandom(), th is erased - assert.False(t, strings.Contains(ot, "th:"), "ot value should not contain th when TraceFlags has no randomness flag and no rv in tracestate, got %q", ot) + assert.NotContains(t, ot, "th:", "ot value should not contain th when TraceFlags has no randomness flag and no rv in tracestate, got %q", ot) assert.Equal(t, "v", result.Tracestate.Get("vendor")) }) @@ -190,7 +190,7 @@ func TestTraceIDRatioBased(t *testing.T) { require.NoError(t, err) parentCtx := trace.ContextWithSpanContext( - context.Background(), + t.Context(), trace.NewSpanContext(trace.SpanContextConfig{ TraceID: traceID, SpanID: spanID, @@ -237,4 +237,125 @@ func TestTraceIDRatioBased(t *testing.T) { assert.Equal(t, sdktrace.Drop, result.Decision) assert.Equal(t, initialState, result.Tracestate) }) + + t.Run("root span RecordAndSample", func(t *testing.T) { + // No parent context - root span. Trace ID randomness >= threshold. + // hasRandomness=false, IsRandom()=false => we erase th; no existing ot => newOtts="" + // => delete ot, return RecordAndSample with empty tracestate. + sampler := TraceIDRatioBased(0.5) + traceID, _ := trace.TraceIDFromHex("00000000000000000080000000000000") + params := sdktrace.SamplingParameters{ + ParentContext: t.Context(), + TraceID: traceID, + } + + result := sampler.ShouldSample(params) + + assert.Equal(t, sdktrace.RecordAndSample, result.Decision) + assert.Empty(t, result.Tracestate.Get("ot")) + }) + + t.Run("root span Drop", func(t *testing.T) { + // No parent context - root span. Trace ID below threshold. + sampler := TraceIDRatioBased(0.5) + traceID, _ := trace.TraceIDFromHex("00000000000000000000000000000000") + params := sdktrace.SamplingParameters{ + ParentContext: t.Context(), + TraceID: traceID, + } + + result := sampler.ShouldSample(params) + + assert.Equal(t, sdktrace.Drop, result.Decision) + assert.Empty(t, result.Tracestate.Get("ot")) + }) + + t.Run("RecordAndSample updates existing th in tracestate", func(t *testing.T) { + // Parent has TraceFlags.IsRandom() and existing ot with th from a different sampler. + // Should replace old th with current sampler's th. + sampler := TraceIDRatioBased(0.5) + traceID, _ := trace.TraceIDFromHex("00000000000000000080000000000000") + spanID, _ := trace.SpanIDFromHex("00f067aa0ba902b7") + initialState, err := trace.ParseTraceState("ot=th:0ad;other:value,vendor=v") + require.NoError(t, err) + + parentCtx := trace.ContextWithSpanContext( + t.Context(), + trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: traceID, + SpanID: spanID, + TraceFlags: trace.FlagsRandom, + TraceState: initialState, + }), + ) + params := sdktrace.SamplingParameters{ + ParentContext: parentCtx, + TraceID: traceID, + } + + result := sampler.ShouldSample(params) + + assert.Equal(t, sdktrace.RecordAndSample, result.Decision) + ot := result.Tracestate.Get("ot") + require.NotEmpty(t, ot) + assert.True(t, strings.HasPrefix(ot, "th:8"), "ot should have updated th for 0.5 sampler, got %q", ot) + assert.Equal(t, "v", result.Tracestate.Get("vendor")) + }) + + t.Run("trace ID all zeros Drop", func(t *testing.T) { + // randomness = 0, any positive threshold causes Drop + sampler := TraceIDRatioBased(0.5) + traceID, _ := trace.TraceIDFromHex("00000000000000000000000000000000") + spanID, _ := trace.SpanIDFromHex("00f067aa0ba902b7") + initialState, err := trace.ParseTraceState("vendor=value") + require.NoError(t, err) + + parentCtx := trace.ContextWithSpanContext( + t.Context(), + trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: traceID, + SpanID: spanID, + TraceFlags: trace.FlagsRandom, + TraceState: initialState, + }), + ) + params := sdktrace.SamplingParameters{ + ParentContext: parentCtx, + TraceID: traceID, + } + + result := sampler.ShouldSample(params) + + assert.Equal(t, sdktrace.Drop, result.Decision) + }) + + t.Run("trace ID max randomness RecordAndSample", func(t *testing.T) { + // randomness = 0x00ffffffffffffff, always samples for any ratio > 0 + sampler := TraceIDRatioBased(0.5) + traceID, _ := trace.TraceIDFromHex("000000000000000000ffffffffffffff") + spanID, _ := trace.SpanIDFromHex("00f067aa0ba902b7") + initialState, err := trace.ParseTraceState("vendor=value") + require.NoError(t, err) + + parentCtx := trace.ContextWithSpanContext( + t.Context(), + trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: traceID, + SpanID: spanID, + TraceFlags: trace.FlagsRandom, + TraceState: initialState, + }), + ) + params := sdktrace.SamplingParameters{ + ParentContext: parentCtx, + TraceID: traceID, + } + + result := sampler.ShouldSample(params) + + assert.Equal(t, sdktrace.RecordAndSample, result.Decision) + ot := result.Tracestate.Get("ot") + require.NotEmpty(t, ot) + assert.True(t, strings.HasPrefix(ot, "th:"), "ot should contain th, got %q", ot) + }) } diff --git a/samplers/probability/traceidratio/tracestate.go b/samplers/probability/traceidratio/tracestate.go index 93620e752f6..0fda6b2611c 100644 --- a/samplers/probability/traceidratio/tracestate.go +++ b/samplers/probability/traceidratio/tracestate.go @@ -20,7 +20,7 @@ func InsertOrUpdateTraceStateThKeyValue(existingOtts, thkv string) string { } start := -1 - end := -1 + var end int if strings.HasPrefix(existingOtts, "th:") { start = 0 } else if idx := strings.Index(existingOtts, ";th:"); idx != -1 { @@ -68,7 +68,7 @@ func tracestateRandomness(otts string) (randomness uint64, hasRandomness bool) { } randomness = rv hasRandomness = true - return + return randomness, hasRandomness } func eraseTraceStateThKeyValue(otts string) string { From 25c7e5a7b7e5b984a7415380203c86f1a32b01ce Mon Sep 17 00:00:00 2001 From: Yuanyuan Zhao <38882162+yuanyuanzhao3@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:55:35 -0400 Subject: [PATCH 7/8] Update samplers/probability/traceidratio/sampler.go Co-authored-by: Joshua MacDonald --- samplers/probability/traceidratio/sampler.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/samplers/probability/traceidratio/sampler.go b/samplers/probability/traceidratio/sampler.go index eb9146bfd8f..451acdf5ec7 100644 --- a/samplers/probability/traceidratio/sampler.go +++ b/samplers/probability/traceidratio/sampler.go @@ -71,6 +71,8 @@ func (ts *Sampler) ShouldSample(p sdktrace.SamplingParameters) sdktrace.Sampling // Only when the randomness we extracted (either from explicit rv value or from trace ID) is present, // can we insert or update the th key-value. Otherwise, we should erase any existing `th` key-value // to signal that the span is not guaranteed to be statistically representative of the trace. + // This logic is specified in + // https://opentelemetry.io/docs/specs/otel/trace/tracestate-probability-sampling/#general-requirements if hasRandomness || psc.TraceFlags().IsRandom() { newOtts = InsertOrUpdateTraceStateThKeyValue(existingOtts, ts.thkv) } else { From 3cd6a7b20919697c067527150d65f4434e73d332 Mon Sep 17 00:00:00 2001 From: Yuanyuan Zhao <38882162+yuanyuanzhao3@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:53:19 -0400 Subject: [PATCH 8/8] [samplers/probability] Re-trigger CI: resolve transient Docker pull failures in codespell and markdown lint checks Made-with: Cursor