diff --git a/CHANGELOG.md b/CHANGELOG.md index 9112801f3de..5323c722722 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. (#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) - Added support for `attribute_count_limit` and `attribute_value_length_limit` in tracer provider configuration in `go.opentelemetry.io/contrib/otelconf`. (#8687) 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..451acdf5ec7 --- /dev/null +++ b/samplers/probability/traceidratio/sampler.go @@ -0,0 +1,140 @@ +// 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 +) + +// 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 Sampler struct { + threshold uint64 + thkv string + description string +} + +// Threshold returns the rejection threshold for testing. +func (ts *Sampler) Threshold() uint64 { + return ts.threshold +} + +// ShouldSample implements sdktrace.Sampler. +func (ts *Sampler) 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 + // 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 { + 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 { + // 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} + } + return sdktrace.SamplingResult{Decision: sdktrace.RecordAndSample, Tracestate: combined} +} + +// Description implements sdktrace.Sampler. +func (ts *Sampler) 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. +// +//nolint:revive // TraceIDRatioBased is the standard OpenTelemetry sampler name +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 &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 new file mode 100644 index 00000000000..9fffb4c404d --- /dev/null +++ b/samplers/probability/traceidratio/sampler_test.go @@ -0,0 +1,361 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package traceidratio + +import ( + "crypto/rand" + mrand "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).(*Sampler) + 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 := mrand.Float64(), mrand.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( + t.Context(), + 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( + 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 value should contain th key, got %q", ot) + 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( + t.Context(), + 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.Contains(t, 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( + t.Context(), + 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.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")) + }) + + 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( + t.Context(), + 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) + 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( + 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) + 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 new file mode 100644 index 00000000000..0fda6b2611c --- /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 + var end int + 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 + } + + 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 + } + randomness = rv + hasRandomness = true + return randomness, hasRandomness +} + +func eraseTraceStateThKeyValue(otts string) string { + start := strings.Index(otts, "th:") + if start == -1 { + return otts + } + if start > 0 && otts[start-1] == ';' { + start-- + } + var end int + 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: