Skip to content

Commit 9ba91a2

Browse files
yuanyuanzhao3MrAliasjmacd
authored
sdk/trace/x: Add experimental ProbabilitySampler (#8123)
## Description Adds an experimental `ProbabilitySampler` in `go.opentelemetry.io/otel/sdk/trace/x` that conforms to the [OpenTelemetry specification's threshold-based sampling algorithm](https://opentelemetry.io/docs/specs/otel/trace/sdk/#probabilitysampler). ### Features - **Threshold-based sampling**: Uses the least significant 56 bits of the trace ID (per W3C Trace Context Level 2 Random Trace ID Flag) for deterministic sampling decisions - **Tracestate `th` handling**: Encodes and propagates the sampling threshold in the W3C `ot` tracestate vendor key for consistent downstream sampling - **Random bit support**: Integrates with `TraceFlags.IsRandom()` and `WithRandom()` (#8012) for proper indication of `th` value for extrapolated metrics support ### Files - `sdk/trace/x/sampler.go` — Core sampler implementation (`ProbabilitySampler`) - `sdk/trace/x/sampler_test.go` — Tests for sampler behavior - `sdk/trace/x/tracestate.go` — Tracestate `th`/`rv` key helpers - `sdk/trace/x/tracestate_test.go` — Tests for tracestate helpers - `sdk/trace/x/README.md` — Documentation for the experimental feature ### Related - Part of #7928 (Support TraceIdRatioBased Sampler) ### Co-Author Joshua MacDonald <jmacd@users.noreply.github.com> ### Benchmarks Benchmarks for `ProbabilitySampler.ShouldSample` and the tracestate `ot.th` / `ot.rv` helpers in `sdk/trace/x`. Scenarios for `BenchmarkProbabilitySamplerShouldSample`: - `record_and_sample_with_explicit_rv` — parent has `ot=rv:...` (≥ threshold); inserts `th` into `ot`. - `drop_with_explicit_low_rv` — parent has `ot=rv:...` (< threshold); fast-path drop, no allocation. - `record_and_sample_replacing_existing_th` — parent has `ot=th:...;rv:...`; in-place replace of `th` in `ot`. - `record_and_sample_from_trace_id_randomness` — no `rv`, randomness derived from `TraceID`. - `probability_one_with_minimal_non-zero_trace_id` — `ProbabilitySampler(1)` fast path with `th:0`. Environment: Apple M1 Max, darwin/arm64, `go1.26.1`. Collected with `-count=10 -benchmem`; summarized with `benchstat`. | Benchmark | sec/op | B/op | allocs/op | | --- | ---: | ---: | ---: | | `ProbabilitySamplerShouldSample/record_and_sample_from_trace_id_randomness` | 57.71n ± 1% | 64.00 ± 0% | 1.000 ± 0% | | `ProbabilitySamplerShouldSample/drop_with_explicit_low_rv` | 38.78n ± 0% | 0.000 ± 0% | 0.000 ± 0% | | `ProbabilitySamplerShouldSample/record_and_sample_replacing_existing_th` | 161.2n ± 0% | 112.0 ± 0% | 2.000 ± 0% | | `ProbabilitySamplerShouldSample/record_and_sample_with_explicit_rv` | 149.8n ± 2% | 112.0 ± 0% | 2.000 ± 0% | | `ProbabilitySamplerShouldSample/probability_one_with_minimal_non-zero_trace_id` | 48.00n ± 1% | 32.00 ± 0% | 1.000 ± 0% | Reproduce: ```sh go test ./sdk/trace/x -run '^$' \ -bench '^BenchmarkProbabilitySamplerShouldSample$' \ -benchmem -count=10 > ps-bench.txt benchstat ps-bench.txt ``` Benchmarks for `TraceIDRatioBased.ShouldSample`. Scenarios for `BenchmarkTraceIDRatioBasedShouldSample`: - `record_and_sample` — `TraceIDRatioBased(0.5)`, TraceID below threshold; no parent state. - `drop` — `TraceIDRatioBased(0.5)`, TraceID above threshold. - `record_and_sample_with_existing_tracestate` — same record path with a `vendor=value` tracestate, exercises pass-through. - `fraction_one_fast_path` — `TraceIDRatioBased(1)` `predeterminedSampler` always-sample. - `fraction_zero_fast_path` — `TraceIDRatioBased(0)` `predeterminedSampler` always-drop. Environment: Apple M1 Max, darwin/arm64, `go1.26.1`. Collected with `-count=10 -benchmem`; summarized with `benchstat`. | Benchmark | sec/op | B/op | allocs/op | | --- | ---: | ---: | ---: | | `TraceIDRatioBasedShouldSample/record_and_sample` | 24.56n ± 3% | 0 | 0 | | `TraceIDRatioBasedShouldSample/drop` | 24.54n ± 1% | 0 | 0 | | `TraceIDRatioBasedShouldSample/record_and_sample_with_existing_tracestate` | 27.54n ± 2% | 0 | 0 | | `TraceIDRatioBasedShouldSample/fraction_one_fast_path` | 19.66n ± 0% | 0 | 0 | | `TraceIDRatioBasedShouldSample/fraction_zero_fast_path` | 19.70n ± 1% | 0 | 0 | Reproduce: ```sh go test ./sdk/trace -run '^$' \ -bench '^BenchmarkTraceIDRatioBasedShouldSample$' \ -benchmem -count=10 > tidr-bench.txt benchstat tidr-bench.txt ``` --------- Co-authored-by: Tyler Yahn <MrAlias@users.noreply.github.com> Co-authored-by: Joshua MacDonald <jmacdonald@microsoft.com> Copilot-Session: 56271494-f68f-4dff-b884-f4e9280275bc
1 parent 771b193 commit 9ba91a2

10 files changed

Lines changed: 1025 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
1010

1111
### Added
1212

13+
- Add experimental `ProbabilitySampler` in `go.opentelemetry.io/otel/sdk/trace/x` that conforms to the [OpenTelemetry specification's threshold-based sampling algorithm](https://opentelemetry.io/docs/specs/otel/trace/sdk/#probabilitysampler). (#8123)
1314
- Add experimental `*Binder` extension interfaces in `go.opentelemetry.io/otel/metric/x` for instruments that support binding attributes ahead of time. (#8760)
1415
- Add the experimental `Finisher` synchronous metric instrument extension interface to `go.opentelemetry.io/otel/metric/x`. (#8906)
1516

sdk/trace/sampling_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,3 +319,88 @@ func TestDescriptions(t *testing.T) {
319319
assert.Equal(t, "TraceIDRatioBased{0}", TraceIDRatioBased(0).Description())
320320
assert.Equal(t, "TraceIDRatioBased{0}", TraceIDRatioBased(-0.5).Description())
321321
}
322+
323+
func BenchmarkTraceIDRatioBasedShouldSample(b *testing.B) {
324+
traceIDSample, err := trace.TraceIDFromHex("00000000000000000000000000000001")
325+
if err != nil {
326+
b.Fatalf("trace ID: %v", err)
327+
}
328+
traceIDDrop, err := trace.TraceIDFromHex("0000000000000000ffffffffffffffff")
329+
if err != nil {
330+
b.Fatalf("trace ID: %v", err)
331+
}
332+
spanID, err := trace.SpanIDFromHex("00f067aa0ba902b7")
333+
if err != nil {
334+
b.Fatalf("span ID: %v", err)
335+
}
336+
337+
tracestate, err := trace.ParseTraceState("vendor=value")
338+
if err != nil {
339+
b.Fatalf("trace state: %v", err)
340+
}
341+
342+
parentWithTracestate := trace.ContextWithSpanContext(
343+
b.Context(),
344+
trace.NewSpanContext(trace.SpanContextConfig{
345+
TraceID: traceIDSample,
346+
SpanID: spanID,
347+
TraceState: tracestate,
348+
}),
349+
)
350+
351+
cases := []struct {
352+
name string
353+
sampler Sampler
354+
params SamplingParameters
355+
}{
356+
{
357+
name: "record and sample",
358+
sampler: TraceIDRatioBased(0.5),
359+
params: SamplingParameters{
360+
ParentContext: b.Context(),
361+
TraceID: traceIDSample,
362+
},
363+
},
364+
{
365+
name: "drop",
366+
sampler: TraceIDRatioBased(0.5),
367+
params: SamplingParameters{
368+
ParentContext: b.Context(),
369+
TraceID: traceIDDrop,
370+
},
371+
},
372+
{
373+
name: "record and sample with existing tracestate",
374+
sampler: TraceIDRatioBased(0.5),
375+
params: SamplingParameters{
376+
ParentContext: parentWithTracestate,
377+
TraceID: traceIDSample,
378+
},
379+
},
380+
{
381+
name: "fraction one fast path",
382+
sampler: TraceIDRatioBased(1),
383+
params: SamplingParameters{
384+
ParentContext: b.Context(),
385+
TraceID: traceIDSample,
386+
},
387+
},
388+
{
389+
name: "fraction zero fast path",
390+
sampler: TraceIDRatioBased(0),
391+
params: SamplingParameters{
392+
ParentContext: b.Context(),
393+
TraceID: traceIDDrop,
394+
},
395+
},
396+
}
397+
398+
for _, tc := range cases {
399+
b.Run(tc.name, func(b *testing.B) {
400+
b.ReportAllocs()
401+
for b.Loop() {
402+
_ = tc.sampler.ShouldSample(tc.params)
403+
}
404+
})
405+
}
406+
}

sdk/trace/x/README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Experimental Features
2+
3+
<!--
4+
Adding/uncommenting the following after initial PR merge:
5+
[![PkgGoDev](https://pkg.go.dev/badge/go.opentelemetry.io/otel/sdk/trace/x)](https://pkg.go.dev/go.opentelemetry.io/otel/sdk/trace/x)
6+
-->
7+
8+
The Trace SDK contains features that have not yet stabilized in the OpenTelemetry specification.
9+
These features are added to the OpenTelemetry Go Trace SDK prior to stabilization in the specification so that users can start experimenting with them and provide feedback.
10+
11+
These features may change in backwards incompatible ways as feedback is applied.
12+
See the [Compatibility and Stability](#compatibility-and-stability) section for more information.
13+
14+
## Features
15+
16+
- [ProbabilitySampler](#probabilitysampler)
17+
18+
### ProbabilitySampler
19+
20+
`ProbabilitySampler` is a threshold-based sampler that conforms to the [OpenTelemetry specification's ProbabilitySampler](https://opentelemetry.io/docs/specs/otel/trace/sdk/#probabilitysampler).
21+
22+
It uses 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)) for deterministic sampling decisions and propagates the sampling threshold via the `th` sub-key in the W3C `ot` tracestate vendor key.
23+
24+
#### Usage
25+
26+
```go
27+
import (
28+
sdktrace "go.opentelemetry.io/otel/sdk/trace"
29+
"go.opentelemetry.io/otel/sdk/trace/x"
30+
)
31+
32+
tp := sdktrace.NewTracerProvider(
33+
sdktrace.WithSampler(
34+
sdktrace.ParentBased(x.ProbabilitySampler(0.5)),
35+
),
36+
)
37+
```
38+
39+
## Compatibility and Stability
40+
41+
Experimental features do not fall within the scope of the OpenTelemetry Go versioning and stability [policy](../../../VERSIONING.md).
42+
These features may be removed or modified in successive version releases, including patch versions.
43+
44+
When an experimental feature is promoted to a stable feature, a migration path will be included in the changelog entry of the release.
45+
There is no guarantee that any environment variable feature flags that enabled the experimental feature will be supported by the stable version.
46+
If they are supported, they may be accompanied with a deprecation notice stating a timeline for the removal of that support.

sdk/trace/x/go.mod

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
module go.opentelemetry.io/otel/sdk/trace/x
2+
3+
go 1.26.0
4+
5+
require (
6+
github.com/stretchr/testify v1.12.1
7+
go.opentelemetry.io/otel v1.47.0-rc.1
8+
go.opentelemetry.io/otel/sdk v1.47.0-rc.1
9+
go.opentelemetry.io/otel/trace v1.47.0-rc.1
10+
)
11+
12+
require (
13+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
14+
github.com/go-logr/logr v1.4.4 // indirect
15+
github.com/go-logr/stdr v1.2.2 // indirect
16+
github.com/google/uuid v1.6.0 // indirect
17+
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
18+
go.opentelemetry.io/otel/log v1.47.0-rc.1 // indirect
19+
go.opentelemetry.io/otel/metric v1.47.0-rc.1 // indirect
20+
go.yaml.in/yaml/v3 v3.0.5 // indirect
21+
golang.org/x/sys v0.47.0 // indirect
22+
)
23+
24+
replace go.opentelemetry.io/otel/trace => ../../../trace
25+
26+
replace go.opentelemetry.io/otel/sdk => ../../
27+
28+
replace go.opentelemetry.io/otel => ../../..
29+
30+
replace go.opentelemetry.io/otel/metric => ../../../metric
31+
32+
replace go.opentelemetry.io/otel/sdk/metric => ../../metric
33+
34+
replace go.opentelemetry.io/otel/metric/x => ../../../metric/x
35+
36+
replace go.opentelemetry.io/otel/log => ../../../log

sdk/trace/x/go.sum

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
2+
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
3+
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
4+
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
5+
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
6+
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
7+
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
8+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
9+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
10+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
11+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
12+
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
13+
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
14+
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
15+
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
16+
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
17+
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
18+
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
19+
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
20+
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
21+
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=

sdk/trace/x/sampler.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright The OpenTelemetry Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Package x contains experimental trace features.
5+
package x
6+
7+
import (
8+
"encoding/binary"
9+
"fmt"
10+
"math"
11+
"strconv"
12+
"strings"
13+
14+
"go.opentelemetry.io/otel"
15+
sdktrace "go.opentelemetry.io/otel/sdk/trace"
16+
"go.opentelemetry.io/otel/trace"
17+
)
18+
19+
const (
20+
// defaultSamplingPrecision is the default precision for threshold encoding.
21+
defaultSamplingPrecision = 4
22+
maxAdjustedCount = 1 << 56
23+
// randomnessMask masks the least significant 56 bits of the trace ID per
24+
// W3C Trace Context Level 2 Random Trace ID Flag.
25+
// https://www.w3.org/TR/trace-context-2/#random-trace-id-flag
26+
randomnessMask = maxAdjustedCount - 1
27+
28+
probabilityZeroThreshold = 1 / float64(maxAdjustedCount)
29+
)
30+
31+
// probabilitySampler is the sdktrace.Sampler implementation used by
32+
// ProbabilitySampler.
33+
type probabilitySampler struct {
34+
threshold uint64
35+
thkv string
36+
description string
37+
}
38+
39+
// ShouldSample implements sdktrace.Sampler.
40+
func (ps *probabilitySampler) ShouldSample(p sdktrace.SamplingParameters) sdktrace.SamplingResult {
41+
psc := trace.SpanContextFromContext(p.ParentContext)
42+
state := psc.TraceState()
43+
44+
existingOtts := state.Get("ot")
45+
46+
var randomness uint64
47+
var hasRandomness bool
48+
if existingOtts != "" {
49+
randomness, hasRandomness = tracestateRandomness(existingOtts)
50+
}
51+
52+
// When there is no explicit randomness, we use the trace ID. Trace ID is presumed to be random
53+
// even without the random flag set.
54+
if !hasRandomness {
55+
randomness = binary.BigEndian.Uint64(p.TraceID[8:16]) & randomnessMask
56+
}
57+
58+
if ps.threshold > randomness {
59+
return sdktrace.SamplingResult{
60+
Decision: sdktrace.Drop,
61+
Tracestate: state,
62+
}
63+
}
64+
65+
newOtts := insertOrUpdateTraceStateThKeyValue(existingOtts, ps.thkv)
66+
67+
if newOtts == "" {
68+
state = state.Delete("ot")
69+
return sdktrace.SamplingResult{Decision: sdktrace.RecordAndSample, Tracestate: state}
70+
}
71+
72+
if existingOtts == newOtts {
73+
return sdktrace.SamplingResult{Decision: sdktrace.RecordAndSample, Tracestate: state}
74+
}
75+
76+
combined, err := state.Insert("ot", newOtts)
77+
if err != nil {
78+
// This should never happen, but we handle it here for code hygiene.
79+
otel.Handle(fmt.Errorf("could not combine tracestate: %w", err))
80+
return sdktrace.SamplingResult{Decision: sdktrace.RecordAndSample, Tracestate: state}
81+
}
82+
return sdktrace.SamplingResult{Decision: sdktrace.RecordAndSample, Tracestate: combined}
83+
}
84+
85+
// Description implements sdktrace.Sampler.
86+
func (ps *probabilitySampler) Description() string {
87+
return ps.description
88+
}
89+
90+
// ProbabilitySampler samples a trace with a given probability. Probabilities >= 1 will
91+
// always sample. Probabilities < 0 are treated as zero. To respect the parent
92+
// trace's SampledFlag, ProbabilitySampler should be used as a
93+
// delegate of a ParentBased sampler.
94+
//
95+
//nolint:revive // revive complains about stutter of `x.ProbabilitySampler`
96+
func ProbabilitySampler(probability float64) sdktrace.Sampler {
97+
const (
98+
maxp = 14
99+
defp = defaultSamplingPrecision
100+
hbits = 4
101+
)
102+
if probability >= 1.0 {
103+
return &probabilitySampler{
104+
threshold: 0,
105+
thkv: "th:0",
106+
description: "ProbabilitySampler{1}",
107+
}
108+
}
109+
if math.IsNaN(probability) || probability < probabilityZeroThreshold {
110+
return sdktrace.NeverSample()
111+
}
112+
113+
_, expF := math.Frexp(probability)
114+
_, expR := math.Frexp(1 - probability)
115+
precision := min(maxp, max(defp+expF/-hbits, defp+expR/-hbits))
116+
117+
scaled := uint64(math.Round(probability * float64(maxAdjustedCount)))
118+
threshold := maxAdjustedCount - scaled
119+
120+
if shift := hbits * (maxp - precision); shift != 0 {
121+
half := uint64(1) << (shift - 1)
122+
threshold += half
123+
threshold >>= shift
124+
threshold <<= shift
125+
}
126+
127+
tvalue := strings.TrimRight(strconv.FormatUint(maxAdjustedCount+threshold, 16)[1:], "0")
128+
return &probabilitySampler{
129+
threshold: threshold,
130+
thkv: "th:" + tvalue,
131+
description: fmt.Sprintf("ProbabilitySampler{%g}", probability),
132+
}
133+
}

0 commit comments

Comments
 (0)