diff --git a/opentelemetry-dotnet-contrib.slnx b/opentelemetry-dotnet-contrib.slnx
index b199176830..3d1922ec16 100644
--- a/opentelemetry-dotnet-contrib.slnx
+++ b/opentelemetry-dotnet-contrib.slnx
@@ -186,6 +186,7 @@
+
@@ -256,9 +257,11 @@
+
+
diff --git a/src/OpenTelemetry.Extensions/.publicApi/PublicAPI.Unshipped.txt b/src/OpenTelemetry.Extensions/.publicApi/PublicAPI.Unshipped.txt
index 064129dc23..2749bbe75b 100644
--- a/src/OpenTelemetry.Extensions/.publicApi/PublicAPI.Unshipped.txt
+++ b/src/OpenTelemetry.Extensions/.publicApi/PublicAPI.Unshipped.txt
@@ -8,10 +8,13 @@ OpenTelemetry.Logs.LogToActivityEventConversionOptions.ScopeConverter.get -> Sys
OpenTelemetry.Logs.LogToActivityEventConversionOptions.ScopeConverter.set -> void
OpenTelemetry.Logs.LogToActivityEventConversionOptions.StateConverter.get -> System.Action>!>!
OpenTelemetry.Logs.LogToActivityEventConversionOptions.StateConverter.set -> void
+OpenTelemetry.ConsistentProbabilitySampler
+OpenTelemetry.ConsistentProbabilitySampler.ConsistentProbabilitySampler(double samplingProbability) -> void
OpenTelemetry.RateLimitingSampler
OpenTelemetry.RateLimitingSampler.RateLimitingSampler(int maxTracesPerSecond) -> void
OpenTelemetry.Trace.BaggageActivityProcessor
OpenTelemetry.Trace.TracerProviderBuilderExtensions
+override OpenTelemetry.ConsistentProbabilitySampler.ShouldSample(in OpenTelemetry.Trace.SamplingParameters samplingParameters) -> OpenTelemetry.Trace.SamplingResult
override OpenTelemetry.RateLimitingSampler.ShouldSample(in OpenTelemetry.Trace.SamplingParameters samplingParameters) -> OpenTelemetry.Trace.SamplingResult
override OpenTelemetry.Trace.BaggageActivityProcessor.OnStart(System.Diagnostics.Activity! data) -> void
static Microsoft.Extensions.Logging.OpenTelemetryLoggingExtensions.AddBaggageProcessor(this OpenTelemetry.Logs.LoggerProviderBuilder! builder) -> OpenTelemetry.Logs.LoggerProviderBuilder!
diff --git a/src/OpenTelemetry.Extensions/CHANGELOG.md b/src/OpenTelemetry.Extensions/CHANGELOG.md
index ecccfdb1ad..884174a612 100644
--- a/src/OpenTelemetry.Extensions/CHANGELOG.md
+++ b/src/OpenTelemetry.Extensions/CHANGELOG.md
@@ -2,6 +2,12 @@
## Unreleased
+* Added `ConsistentProbabilitySampler`, a consistent probability based sampler
+ implementing the OpenTelemetry
+ [probability sampling](https://opentelemetry.io/docs/specs/otel/trace/tracestate-probability-sampling/)
+ specification.
+ ([#4629](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/4629))
+
## 1.17.0-beta.1
Released 2026-Jul-17
diff --git a/src/OpenTelemetry.Extensions/Internal/ConsistentProbability.cs b/src/OpenTelemetry.Extensions/Internal/ConsistentProbability.cs
new file mode 100644
index 0000000000..06ec982d2d
--- /dev/null
+++ b/src/OpenTelemetry.Extensions/Internal/ConsistentProbability.cs
@@ -0,0 +1,243 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Globalization;
+
+namespace OpenTelemetry.Extensions.Internal;
+
+///
+/// Helpers for converting between sampling probabilities, 56-bit rejection thresholds and their
+/// hexadecimal th/rv encodings, following the OpenTelemetry
+///
+/// probability sampling and
+/// tracestate handling
+/// specifications.
+///
+internal static class ConsistentProbability
+{
+ ///
+ /// The maximum number of hexadecimal digits used to encode a 56-bit value.
+ ///
+ public const int MaxHexDigits = 14;
+
+ ///
+ /// The default encoding precision recommended by the specification.
+ ///
+ public const int DefaultPrecision = 4;
+
+ ///
+ /// 2^56, the number of distinct 56-bit values (the maximum adjusted count).
+ ///
+ public const long MaxAdjustedCount = 1L << 56;
+
+ ///
+ /// The largest valid randomness value, 2^56 - 1.
+ ///
+ public const long MaxRandomValue = MaxAdjustedCount - 1;
+
+ ///
+ /// Encodes a sampling probability as a th value using the specified precision.
+ ///
+ /// The sampling probability, in the range (0, 1].
+ /// The number of significant hexadecimal digits, in the range [1, 14].
+ /// The threshold encoded with trailing zeros removed (for example fd70a).
+ ///
+ /// This computes the exact 56-bit rejection threshold directly from the probability, matching the
+ /// OpenTelemetry Collector implementation rather than the (less accurate) floating-point reference
+ /// pseudocode in the specification, so that values near 0 and 1 are encoded exactly:
+ ///
+ /// ProbabilityToThresholdWithPrecision.
+ ///
+ public static string EncodeThreshold(double probability, int precision)
+ {
+ if (probability >= 1.0)
+ {
+ // Special case: 100% sampling has a rejection threshold of zero.
+ return "0";
+ }
+
+ const int HexBits = 4; // 4 bits per hex digit
+
+ // Raise the precision by the number of leading '0' or 'f' digits so the configured precision
+ // applies to the significant digits of the threshold near both 0 and 1. frexp returns an
+ // exponent <= 0; every multiple of -4 corresponds to another leading '0' or 'f' hex digit.
+ var exponentFraction = FrexpExponent(probability);
+ var exponentRejection = FrexpExponent(1.0 - probability);
+
+ precision = Math.Min(
+ MaxHexDigits,
+ Math.Max(precision + (exponentFraction / -HexBits), precision + (exponentRejection / -HexBits)));
+
+ // Compute the rejection threshold as a 56-bit integer: T = 2^56 - round(probability * 2^56).
+ var scaled = (long)Math.Round(probability * MaxAdjustedCount, MidpointRounding.AwayFromZero);
+ var threshold = MaxAdjustedCount - scaled;
+
+ // Round to the requested precision by dropping the low hex digits, rounding to nearest.
+ var shift = HexBits * (MaxHexDigits - precision);
+
+ if (shift > 0)
+ {
+ var half = 1L << (shift - 1);
+ threshold = ((threshold + half) >> shift) << shift;
+ }
+
+ return EncodeThresholdInteger(threshold);
+ }
+
+ ///
+ /// Encodes a 56-bit integer rejection threshold as a th value, with trailing zeros removed.
+ ///
+ /// The rejection threshold, in the range [0, 2^56).
+ /// The encoded threshold (for example 8 for 50% sampling).
+ public static string EncodeThresholdInteger(long threshold)
+ {
+ if (threshold <= 0)
+ {
+ return "0";
+ }
+
+ const string Format = "x14"; // 14 hex digits, no leading "0x"
+
+#if NET
+ Span buffer = stackalloc char[MaxHexDigits];
+
+ _ = threshold.TryFormat(buffer, out var written, Format, CultureInfo.InvariantCulture);
+
+ var trimmed = buffer.Slice(0, written).TrimEnd('0');
+
+ return trimmed.IsEmpty ? "0" : new string(trimmed);
+#else
+ var hex = threshold.ToString(Format, CultureInfo.InvariantCulture).TrimEnd('0');
+ return hex.Length == 0 ? "0" : hex;
+#endif
+ }
+
+ ///
+ /// Decodes a th value into a 56-bit integer rejection threshold by extending it with
+ /// trailing zeros to 14 digits and parsing the result.
+ ///
+ /// The encoded threshold (1 to 14 lowercase hexadecimal digits).
+ /// The rejection threshold, in the range [0, 2^56).
+ public static long DecodeThreshold(string threshold)
+ {
+ _ = TryDecodeThreshold(threshold.AsSpan(), out var value);
+ return value;
+ }
+
+ ///
+ /// Attempts to decode a th value into a 56-bit integer rejection threshold.
+ ///
+ /// The encoded threshold (1 to 14 lowercase hexadecimal digits).
+ /// The rejection threshold when successful; otherwise zero.
+ /// if the value was decoded; otherwise .
+ public static bool TryDecodeThreshold(ReadOnlySpan threshold, out long value)
+ {
+ if (threshold.IsEmpty || threshold.Length > MaxHexDigits || !TryParseHex56(threshold, out var parsed))
+ {
+ value = 0;
+ return false;
+ }
+
+ // Extend the value with trailing zeros to 14 digits, i.e. shift left by 4 bits per omitted digit.
+ var shift = 4 * (MaxHexDigits - threshold.Length);
+ value = shift > 0 ? parsed << shift : parsed;
+
+ return true;
+ }
+
+ ///
+ /// Parses a lowercase hexadecimal string of 1 to 14 digits into its integer value.
+ ///
+ /// The hexadecimal string.
+ /// The parsed value when successful; otherwise zero.
+ /// if the value was parsed; otherwise .
+ public static bool TryParseHex56(string? value, out long result)
+ => TryParseHex56(value.AsSpan(), out result);
+
+ ///
+ /// Parses a lowercase hexadecimal span of 1 to 14 digits into its integer value.
+ ///
+ /// The hexadecimal characters.
+ /// The parsed value when successful; otherwise zero.
+ /// if the value was parsed; otherwise .
+ ///
+ /// Uppercase digits are rejected: the specification requires both th and rv to be
+ /// encoded with lowercase hexadecimal digits, the same as .
+ ///
+ public static bool TryParseHex56(ReadOnlySpan value, out long result)
+ {
+ result = 0;
+
+ if (value.IsEmpty || value.Length > MaxHexDigits)
+ {
+ return false;
+ }
+
+ long parsed = 0;
+
+ foreach (var ch in value)
+ {
+ var digit = ch switch
+ {
+ >= '0' and <= '9' => ch - '0',
+ >= 'a' and <= 'f' => ch - 'a' + 10,
+ _ => -1,
+ };
+
+ if (digit < 0)
+ {
+ return false;
+ }
+
+ parsed = (parsed << 4) | (long)digit;
+ }
+
+ result = parsed;
+ return true;
+ }
+
+ ///
+ /// Calculates the sampling probability represented by a rejection threshold.
+ ///
+ /// The rejection threshold, in the range [0, 2^56).
+ ///
+ /// The sampling probability, in the range (0, 1].
+ ///
+ ///
+ /// Per the specification: Probability = (MaxAdjustedCount - Threshold) / MaxAdjustedCount.
+ ///
+ public static double ThresholdToProbability(long threshold)
+ => (double)(MaxAdjustedCount - threshold) / MaxAdjustedCount;
+
+ ///
+ /// Calculates the adjusted count (inverse sampling probability) for a rejection threshold.
+ ///
+ /// The rejection threshold, in the range [0, 2^56).
+ ///
+ /// The adjusted count.
+ ///
+ ///
+ /// Per the specification: AdjustedCount = MaxAdjustedCount / (MaxAdjustedCount - Threshold).
+ ///
+ public static double ThresholdToAdjustedCount(long threshold)
+ => (double)MaxAdjustedCount / (MaxAdjustedCount - threshold);
+
+ ///
+ /// Returns the exponent that math.frexp would produce for a positive value in (0, 1],
+ /// i.e. the value e such that value = m * 2^e with 0.5 <= m < 1.
+ ///
+ private static int FrexpExponent(double value)
+ {
+ // value is a positive, normal double in (0, 1] (1.0 arises when 1 - probability rounds up).
+#if NET
+ return Math.ILogB(value) + 1;
+#else
+ var bits = BitConverter.DoubleToInt64Bits(value);
+ var biasedExponent = (int)((bits >> 52) & 0x7FF);
+
+ // frexp normalises the mantissa to [0.5, 1) rather than [1, 2), so its exponent is one
+ // greater than the unbiased IEEE-754 exponent (biasedExponent - 1023).
+ return biasedExponent - 1022;
+#endif
+ }
+}
diff --git a/src/OpenTelemetry.Extensions/Internal/OpenTelemetryExtensionsEventSource.cs b/src/OpenTelemetry.Extensions/Internal/OpenTelemetryExtensionsEventSource.cs
index 94c752c857..9d1cd55fb8 100644
--- a/src/OpenTelemetry.Extensions/Internal/OpenTelemetryExtensionsEventSource.cs
+++ b/src/OpenTelemetry.Extensions/Internal/OpenTelemetryExtensionsEventSource.cs
@@ -24,10 +24,7 @@ public void LogProcessorException(string @event, Exception ex)
}
[Event(1, Message = "Unknown error in LogProcessor event '{0}': '{1}'.", Level = EventLevel.Error)]
- public void LogProcessorException(string @event, string exception)
- {
- this.WriteEvent(1, @event, exception);
- }
+ public void LogProcessorException(string @event, string exception) => this.WriteEvent(1, @event, exception);
[NonEvent]
public void LogRecordFilterException(string? categoryName, Exception ex)
@@ -40,19 +37,21 @@ public void LogRecordFilterException(string? categoryName, Exception ex)
[Event(2, Message = "Filter threw an exception, log record will not be attached to an activity, the log record would flow to its pipeline unaffected. CategoryName: '{0}', Exception: {1}.", Level = EventLevel.Warning)]
public void LogRecordFilterException(string? categoryName, string exception)
- {
- this.WriteEvent(2, categoryName, exception);
- }
+ => this.WriteEvent(2, categoryName, exception);
[Event(3, Message = "Baggage key predicate threw exception when trying to add baggage entry with key '{0}'. Baggage entry will not be added to the activity. Exception: '{1}'", Level = EventLevel.Warning)]
public void BaggageKeyActivityPredicateException(string baggageKey, string exception)
- {
- this.WriteEvent(3, baggageKey, exception);
- }
+ => this.WriteEvent(3, baggageKey, exception);
[Event(4, Message = "Baggage key predicate threw exception when trying to add baggage entry with key '{0}'. Baggage entry will not be added to the log record. Exception: '{1}'", Level = EventLevel.Warning)]
public void BaggageKeyLogRecordPredicateException(string baggageKey, string exception)
- {
- this.WriteEvent(4, baggageKey, exception);
- }
+ => this.WriteEvent(4, baggageKey, exception);
+
+ [Event(5, Message = "Sampler '{0}' presumed the TraceID to be random for a context where the W3C trace random flag is not set. Sampling decisions may be inconsistent with other participants in the trace until every SDK in the system implements the W3C Trace Context Level 2 randomness requirements. This warning is reported once per sampler.", Level = EventLevel.Warning)]
+ public void PresumedTraceIdRandomness(string sampler)
+ => this.WriteEvent(5, sampler);
+
+ [Event(6, Message = "Sampler '{0}' could not add its sampling threshold to the OpenTelemetry tracestate because the ot value would exceed 256 characters. Existing OpenTelemetry tracestate values were preserved and the outgoing sampling probability is unknown. This warning is reported once per sampler.", Level = EventLevel.Warning)]
+ public void TraceStateSizeLimitExceeded(string sampler)
+ => this.WriteEvent(6, sampler);
}
diff --git a/src/OpenTelemetry.Extensions/Internal/OtelTraceState.cs b/src/OpenTelemetry.Extensions/Internal/OtelTraceState.cs
new file mode 100644
index 0000000000..a624953c06
--- /dev/null
+++ b/src/OpenTelemetry.Extensions/Internal/OtelTraceState.cs
@@ -0,0 +1,482 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Globalization;
+using System.Text;
+
+namespace OpenTelemetry.Extensions.Internal;
+
+///
+/// Parses and serializes the OpenTelemetry ot entry of a W3C tracestate, exposing the
+/// th (rejection threshold) and rv (explicit randomness value) sub-keys used for
+/// consistent probability sampling, while preserving any other ot sub-keys and unrelated
+/// tracestate members.
+///
+internal struct OtelTraceState
+{
+ ///
+ /// The W3C tracestate key that holds OpenTelemetry values.
+ ///
+ public const string TraceStateKey = "ot";
+
+ ///
+ /// The ot sub-key holding the rejection threshold.
+ ///
+ public const string ThresholdSubKey = "th";
+
+ ///
+ /// The ot sub-key holding the explicit randomness value.
+ ///
+ public const string RandomValueSubKey = "rv";
+
+ ///
+ /// The maximum length of the serialized ot value, per the specification.
+ ///
+ public const int TraceStateSizeLimit = 256;
+
+ ///
+ /// The maximum number of members in a W3C tracestate value.
+ ///
+ public const int TraceStateMemberLimit = 32;
+
+ private string? encodedThreshold;
+ private List>? otherSubKeys;
+ private List? otherMembers;
+
+ ///
+ /// Gets the rejection threshold.
+ /// Only meaningful when is .
+ ///
+ public long Threshold { get; private set; }
+
+ ///
+ /// Gets the explicit randomness value.
+ /// Only meaningful when is .
+ ///
+ public long RandomValue { get; private set; }
+
+ ///
+ /// Gets a value indicating whether a valid threshold is present.
+ ///
+ public bool HasThreshold { get; private set; }
+
+ ///
+ /// Gets a value indicating whether a valid randomness value is present.
+ ///
+ public bool HasRandomValue { get; private set; }
+
+ /// Parses a W3C tracestate string.
+ /// The tracestate value, which may be or empty.
+ /// The parsed .
+ public static OtelTraceState Parse(string? traceState)
+ {
+ var state = default(OtelTraceState);
+
+ if (string.IsNullOrEmpty(traceState))
+ {
+ return state;
+ }
+
+ var remaining = traceState.AsSpan();
+ while (!remaining.IsEmpty)
+ {
+ var comma = remaining.IndexOf(',');
+ var member = (comma < 0 ? remaining : remaining.Slice(0, comma)).Trim();
+ remaining = comma < 0 ? default : remaining.Slice(comma + 1);
+
+ if (member.IsEmpty)
+ {
+ continue;
+ }
+
+ var separator = member.IndexOf('=');
+ if (separator <= 0)
+ {
+ // Malformed member: preserve it verbatim rather than discarding data.
+ state.otherMembers ??= [];
+ state.otherMembers.Add(member.ToString());
+ continue;
+ }
+
+ if (member.Slice(0, separator).Equals(TraceStateKey, StringComparison.Ordinal))
+ {
+ var parsedOtValue = default(OtelTraceState);
+ if (parsedOtValue.TryParseOtValue(member.Slice(separator + 1)))
+ {
+ state.MergeOtValue(in parsedOtValue);
+ }
+ }
+ else
+ {
+ state.otherMembers ??= [];
+ state.otherMembers.Add(member.ToString());
+ }
+ }
+
+ return state;
+ }
+
+ ///
+ /// Attempts to set the rejection threshold without exceeding the ot value size limit.
+ ///
+ /// The rejection threshold, in the range [0, 2^56).
+ ///
+ /// if the threshold was set; otherwise .
+ ///
+ ///
+ /// If the threshold does not fit, any existing threshold is erased so the outgoing sampling
+ /// probability is unknown, while all other ot values remain unchanged.
+ ///
+ public bool TrySetThreshold(long threshold)
+ {
+ var encodedThreshold = ConsistentProbability.EncodeThresholdInteger(threshold);
+ var lengthWithoutThreshold = this.GetOtValueLengthWithoutThreshold();
+
+ var lengthWithThreshold = GetLengthWithSubKey(
+ lengthWithoutThreshold,
+ ThresholdSubKey.Length,
+ encodedThreshold.Length);
+
+ if (lengthWithThreshold > TraceStateSizeLimit)
+ {
+ this.ClearThreshold();
+ return false;
+ }
+
+ this.Threshold = threshold;
+ this.HasThreshold = true;
+ this.encodedThreshold = encodedThreshold;
+
+ return true;
+ }
+
+ ///
+ /// Removes the rejection threshold, marking the sampling probability as unknown.
+ ///
+ public void ClearThreshold()
+ {
+ this.Threshold = 0;
+ this.HasThreshold = false;
+ this.encodedThreshold = null;
+ }
+
+ ///
+ /// Sets the explicit randomness value.
+ ///
+ /// The randomness value, in the range [0, 2^56).
+ public void SetRandomValue(long randomValue)
+ {
+ this.RandomValue = randomValue;
+ this.HasRandomValue = true;
+ }
+
+ ///
+ /// Serializes the state back into a W3C tracestate string.
+ ///
+ ///
+ /// The serialized tracestate, or an empty string when there is nothing to emit.
+ ///
+ public readonly string Serialize()
+ {
+ var hasOtContent = this.HasThreshold || this.HasRandomValue || this.otherSubKeys is { Count: > 0 };
+
+ if (!hasOtContent && this.otherMembers is not { Count: > 0 })
+ {
+ return string.Empty;
+ }
+
+ var builder = new StringBuilder();
+ var memberCount = 0;
+
+ if (hasOtContent)
+ {
+ this.AppendOtEntry(builder);
+ memberCount = builder.Length > 0 ? 1 : 0;
+ }
+
+ if (this.otherMembers is { Count: > 0 } other)
+ {
+ foreach (var member in other)
+ {
+ if (memberCount == TraceStateMemberLimit)
+ {
+ break;
+ }
+
+ if (builder.Length > 0)
+ {
+ builder.Append(',');
+ }
+
+ builder.Append(member);
+ memberCount++;
+ }
+ }
+
+ return builder.ToString();
+ }
+
+ private static void AppendSubKey(StringBuilder builder, int valueIndex, string name, string value)
+ {
+ if (builder.Length > valueIndex)
+ {
+ builder.Append(';');
+ }
+
+ builder.Append(name)
+ .Append(':')
+ .Append(value);
+ }
+
+ private static void AppendHex14(StringBuilder builder, long value)
+ {
+ const string Format = "x14";
+
+#if NET
+ Span buffer = stackalloc char[ConsistentProbability.MaxHexDigits];
+ _ = value.TryFormat(buffer, out var written, Format, CultureInfo.InvariantCulture);
+ builder.Append(buffer.Slice(0, written));
+#else
+ builder.Append(value.ToString(Format, CultureInfo.InvariantCulture));
+#endif
+ }
+
+ private static int GetLengthWithSubKey(int currentLength, int nameLength, int valueLength)
+ => currentLength + (currentLength > 0 ? 1 : 0) + nameLength + 1 + valueLength;
+
+ private static bool IsValidOtSubKey(ReadOnlySpan name)
+ {
+ if (name.IsEmpty || !char.IsAsciiLetterLower(name[0]))
+ {
+ return false;
+ }
+
+ foreach (var ch in name.Slice(1))
+ {
+ if (!char.IsAsciiDigit(ch) && !char.IsAsciiLetterLower(ch))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool IsValidOtSubValue(ReadOnlySpan value)
+ {
+ foreach (var ch in value)
+ {
+ if (!char.IsAsciiLetterOrDigit(ch) &&
+ ch is not '.' and not '_' and not '-')
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private readonly bool ContainsOtherSubKey(ReadOnlySpan name)
+ {
+ if (this.otherSubKeys is not { Count: > 0 } other)
+ {
+ return false;
+ }
+
+ foreach (var subKey in other)
+ {
+ if (name.Equals(subKey.Key, StringComparison.Ordinal))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private void MergeOtValue(in OtelTraceState parsed)
+ {
+ if (parsed.HasThreshold)
+ {
+ this.Threshold = parsed.Threshold;
+ this.HasThreshold = true;
+ this.encodedThreshold = parsed.encodedThreshold;
+ }
+
+ if (parsed.HasRandomValue)
+ {
+ this.RandomValue = parsed.RandomValue;
+ this.HasRandomValue = true;
+ }
+
+ if (parsed.otherSubKeys is { Count: > 0 } other)
+ {
+ this.otherSubKeys ??= [];
+ this.otherSubKeys.AddRange(other);
+ }
+ }
+
+ private bool TryParseOtValue(ReadOnlySpan otValue)
+ {
+ if (otValue.IsEmpty || otValue.Length > TraceStateSizeLimit)
+ {
+ return false;
+ }
+
+ var hasThreshold = false;
+ var hasRandomValue = false;
+
+ while (true)
+ {
+ var semicolon = otValue.IndexOf(';');
+ var pair = semicolon < 0 ? otValue : otValue.Slice(0, semicolon);
+
+ if (pair.IsEmpty)
+ {
+ return false;
+ }
+
+ var separator = pair.IndexOf(':');
+ if (separator <= 0)
+ {
+ return false;
+ }
+
+ var name = pair.Slice(0, separator);
+ var value = pair.Slice(separator + 1);
+
+ if (!IsValidOtSubKey(name) || !IsValidOtSubValue(value))
+ {
+ return false;
+ }
+
+ if (name.Equals(ThresholdSubKey, StringComparison.Ordinal))
+ {
+ if (hasThreshold)
+ {
+ return false;
+ }
+
+ hasThreshold = true;
+
+ // A th value has 1 to 14 lowercase hexadecimal digits; it is extended with trailing
+ // zeros to 14 digits when decoded. An invalid value leaves the threshold erased.
+ if (ConsistentProbability.TryDecodeThreshold(value, out var parsed))
+ {
+ this.Threshold = parsed;
+ this.HasThreshold = true;
+ this.encodedThreshold = ConsistentProbability.EncodeThresholdInteger(parsed);
+ }
+ }
+ else if (name.Equals(RandomValueSubKey, StringComparison.Ordinal))
+ {
+ if (hasRandomValue)
+ {
+ return false;
+ }
+
+ hasRandomValue = true;
+
+ // An rv value must be exactly 14 lowercase hexadecimal digits.
+ if (value.Length == ConsistentProbability.MaxHexDigits &&
+ ConsistentProbability.TryParseHex56(value, out var parsed))
+ {
+ this.RandomValue = parsed;
+ this.HasRandomValue = true;
+ }
+ }
+ else
+ {
+ if (this.ContainsOtherSubKey(name))
+ {
+ return false;
+ }
+
+ this.otherSubKeys ??= [];
+ this.otherSubKeys.Add(new(name.ToString(), value.ToString()));
+ }
+
+ if (semicolon < 0)
+ {
+ return true;
+ }
+
+ otValue = otValue.Slice(semicolon + 1);
+ }
+ }
+
+ private readonly int GetOtValueLengthWithoutThreshold()
+ {
+ var length = 0;
+
+ if (this.HasRandomValue)
+ {
+ length = GetLengthWithSubKey(length, RandomValueSubKey.Length, ConsistentProbability.MaxHexDigits);
+ }
+
+ if (this.otherSubKeys is { Count: > 0 } other)
+ {
+ foreach (var subKey in other)
+ {
+ length = GetLengthWithSubKey(length, subKey.Key.Length, subKey.Value.Length);
+ }
+ }
+
+ return length;
+ }
+
+ private readonly void AppendOtEntry(StringBuilder builder)
+ {
+ var prefixIndex = builder.Length;
+
+ builder.Append(TraceStateKey)
+ .Append('=');
+
+ var valueIndex = builder.Length;
+
+ if (this.HasThreshold)
+ {
+ AppendSubKey(
+ builder,
+ valueIndex,
+ ThresholdSubKey,
+ this.encodedThreshold ?? ConsistentProbability.EncodeThresholdInteger(this.Threshold));
+ }
+
+ if (this.HasRandomValue)
+ {
+ if (builder.Length > valueIndex)
+ {
+ builder.Append(';');
+ }
+
+ builder.Append(RandomValueSubKey)
+ .Append(':');
+
+ AppendHex14(builder, this.RandomValue);
+ }
+
+ if (this.otherSubKeys is { Count: > 0 } other)
+ {
+ foreach (var subKey in other)
+ {
+ // Preserve additional sub-keys only while the ot value stays within the size limit.
+ var otValueLength = builder.Length - valueIndex;
+ var lengthWithSubKey = GetLengthWithSubKey(otValueLength, subKey.Key.Length, subKey.Value.Length);
+
+ if (lengthWithSubKey > TraceStateSizeLimit)
+ {
+ continue;
+ }
+
+ AppendSubKey(builder, valueIndex, subKey.Key, subKey.Value);
+ }
+ }
+
+ if (builder.Length == valueIndex)
+ {
+ // Only oversized sub-keys were present, so remove the empty "ot=" prefix.
+ builder.Length = prefixIndex;
+ }
+ }
+}
diff --git a/src/OpenTelemetry.Extensions/OpenTelemetry.Extensions.csproj b/src/OpenTelemetry.Extensions/OpenTelemetry.Extensions.csproj
index 4db4e7d105..333c73a3e8 100644
--- a/src/OpenTelemetry.Extensions/OpenTelemetry.Extensions.csproj
+++ b/src/OpenTelemetry.Extensions/OpenTelemetry.Extensions.csproj
@@ -18,6 +18,7 @@
+
@@ -26,4 +27,8 @@
+
+
+
+
diff --git a/src/OpenTelemetry.Extensions/README.md b/src/OpenTelemetry.Extensions/README.md
index b15dd68030..ba422f1608 100644
--- a/src/OpenTelemetry.Extensions/README.md
+++ b/src/OpenTelemetry.Extensions/README.md
@@ -130,3 +130,34 @@ builder.Services.AddOpenTelemetry()
.SetSampler(new ParentBasedSampler(new RateLimitingSampler(3)))
});
```
+
+### ConsistentProbabilitySampler
+
+The `ConsistentProbabilitySampler` samples a configured proportion of spans and
+records the sampling probability in the `tracestate`, following the OpenTelemetry
+[probability sampling](https://opentelemetry.io/docs/specs/otel/trace/tracestate-probability-sampling/)
+specification. Because all participants in a trace share the same source of
+randomness, their sampling decisions are consistent with one another.
+
+Like the built-in `TraceIdRatioBased` sampler, it makes an independent decision,
+so combine it with a `ParentBasedSampler` to follow the parent's decision for
+non-root spans.
+
+The shared randomness comes from the explicit randomness value (the `rv` sub-key
+of the `ot` `tracestate` entry) when one is present, and otherwise from the
+least-significant 56 bits of the `TraceId`. The sampler never creates or modifies
+an explicit randomness value, so every participant that observes the same context
+resolves the same randomness.
+
+An example of `ConsistentProbabilitySampler` usage is shown below:
+
+```cs
+builder.Services.AddOpenTelemetry()
+ .WithTracing(tracing =>
+ {
+ tracing.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ // Sample approximately 10% of traces consistently
+ .SetSampler(new ParentBasedSampler(new ConsistentProbabilitySampler(0.1)));
+ });
+```
diff --git a/src/OpenTelemetry.Extensions/Trace/ConsistentProbabilitySampler.cs b/src/OpenTelemetry.Extensions/Trace/ConsistentProbabilitySampler.cs
new file mode 100644
index 0000000000..ed129f2a41
--- /dev/null
+++ b/src/OpenTelemetry.Extensions/Trace/ConsistentProbabilitySampler.cs
@@ -0,0 +1,157 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Diagnostics;
+using OpenTelemetry.Extensions.Internal;
+using OpenTelemetry.Trace;
+
+namespace OpenTelemetry;
+
+///
+/// A that makes consistent probability based sampling decisions following the
+/// OpenTelemetry
+///
+/// probability sampling specification.
+///
+///
+/// Because all participants in a trace share the same source of randomness, their sampling decisions
+/// are consistent with one another. Like the built-in TraceIdRatioBased sampler, this sampler
+/// makes an independent decision, so combine it with a parent based sampler to follow the parent's
+/// decision for non-root spans.
+///
+public sealed class ConsistentProbabilitySampler : Sampler
+{
+ // The W3C Trace Context Level 2 "random" trace flag, which indicates that the least-significant
+ // 56 bits of the TraceID were generated in a random or pseudo-random manner.
+ // https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/3867
+ // will change this code to use ActivityTraceFlags.RandomTraceId.
+ private const ActivityTraceFlags RandomTraceIdFlag = (ActivityTraceFlags)0x02;
+
+ private readonly long threshold;
+
+ private int hasWarnedAboutPresumedRandomness;
+ private int hasWarnedAboutTraceStateSizeLimit;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// The probability with which spans are sampled, in the range [2^-56, 1].
+ ///
+ ///
+ /// is not a number, or is outside the range [2^-56, 1].
+ ///
+ public ConsistentProbabilitySampler(double samplingProbability)
+ {
+ // The smallest probability representable by the 56-bit randomness range used by the
+ // specification is 2^-56 (i.e. an adjusted count of 2^56).
+ const double MinProbability = 1.0 / ConsistentProbability.MaxAdjustedCount;
+
+ if (double.IsNaN(samplingProbability) || samplingProbability < MinProbability || samplingProbability > 1.0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(samplingProbability),
+ samplingProbability,
+ "Value must be in the range [2^-56, 1].");
+ }
+
+ // Round the probability to the encoded threshold once, so the sampling decision matches the
+ // threshold that is propagated to downstream participants.
+ var encoded = ConsistentProbability.EncodeThreshold(samplingProbability, ConsistentProbability.DefaultPrecision);
+ this.threshold = ConsistentProbability.DecodeThreshold(encoded);
+
+ this.Description = FormattableString.Invariant($"ConsistentProbabilitySampler{{{samplingProbability}}}");
+ }
+
+ ///
+ public override SamplingResult ShouldSample(in SamplingParameters samplingParameters)
+ {
+ var parentContext = samplingParameters.ParentContext;
+ var traceState = OtelTraceState.Parse(parentContext.TraceState);
+
+ // "A common random value (that is known or propagated to all participants) is the main
+ // ingredient that enables consistent probability sampling." The specification supports two
+ // sources: an explicit rv value, or the least-significant 56 bits of the TraceID.
+ long randomness;
+ if (traceState.HasRandomValue)
+ {
+ // Prefer the explicit randomness value. "Explicit randomness values are meant to
+ // propagate through span contexts unmodified", and "SDKs and Samplers MUST NOT
+ // overwrite explicit randomness in an OpenTelemetry TraceState value".
+ randomness = traceState.RandomValue;
+ }
+ else
+ {
+ // "Samplers SHOULD presume that TraceIDs meet the W3C Trace Context Level 2 randomness
+ // requirements, unless an explicit randomness value is present in the rv sub-key."
+ //
+ // Deriving the randomness from the TraceID, rather than generating a new value, is what
+ // keeps this decision consistent with every other participant that observes the same
+ // TraceID. Generating one here would be permitted for a root Context only ("The Root
+ // sampling decision is the only case where it is permitted to modify the explicit trace
+ // randomness value for a Context"), but a generated value only reaches other
+ // participants through the tracestate header, whereas the TraceID always travels with
+ // the trace.
+ if (parentContext.IsValid() && (parentContext.TraceFlags & RandomTraceIdFlag) == 0)
+ {
+ // "To assist with this migration, the TraceIdRatioBased Sampler issues a warning
+ // statement the first time it presumes TraceID randomness for a Context where the
+ // Trace random flag is not set."
+ this.WarnOncePresumingTraceIdRandomness();
+ }
+
+ randomness = GetRandomnessFromTraceId(samplingParameters.TraceId);
+ }
+
+ // "If R >= T, keep the span, else drop the span."
+ var sampled = randomness >= this.threshold;
+
+ if (sampled)
+ {
+ // "When a Span or Context is sampled, the sampler's effective T is encoded in the
+ // OpenTelemetry TraceState th sub-key to indicate its sampling probability."
+ if (!traceState.TrySetThreshold(this.threshold))
+ {
+ this.WarnOnceTraceStateSizeLimitExceeded();
+ }
+ }
+ else
+ {
+ // "Sampling stages that yield spans with unknown sampling probability [...] must erase
+ // the OpenTelemetry threshold value in their output."
+ traceState.ClearThreshold();
+ }
+
+ return new(
+ sampled ? SamplingDecision.RecordAndSample : SamplingDecision.Drop,
+ traceState.Serialize());
+ }
+
+ private static long GetRandomnessFromTraceId(ActivityTraceId traceId)
+ {
+ // The randomness is the trailing 7 bytes (56 bits) of the 16-byte (32 hexadecimal digit) TraceId.
+ var hex = traceId.ToHexString();
+ _ = ConsistentProbability.TryParseHex56(hex.AsSpan(hex.Length - ConsistentProbability.MaxHexDigits), out var value);
+ return value;
+ }
+
+ private void WarnOncePresumingTraceIdRandomness()
+ {
+ // The relaxed read keeps the common case (already warned) off the interlocked path, as this
+ // runs for every span that does not carry the random trace flag.
+ if (Volatile.Read(ref this.hasWarnedAboutPresumedRandomness) == 0 &&
+ Interlocked.Exchange(ref this.hasWarnedAboutPresumedRandomness, 1) == 0)
+ {
+ OpenTelemetryExtensionsEventSource.Log.PresumedTraceIdRandomness(this.Description);
+ }
+ }
+
+ private void WarnOnceTraceStateSizeLimitExceeded()
+ {
+ if (Volatile.Read(ref this.hasWarnedAboutTraceStateSizeLimit) == 0 &&
+ Interlocked.Exchange(ref this.hasWarnedAboutTraceStateSizeLimit, 1) == 0)
+ {
+ OpenTelemetryExtensionsEventSource.Log.TraceStateSizeLimitExceeded(this.Description);
+ }
+ }
+}
diff --git a/src/Shared/CharExtensions.cs b/src/Shared/CharExtensions.cs
new file mode 100644
index 0000000000..1e506e5627
--- /dev/null
+++ b/src/Shared/CharExtensions.cs
@@ -0,0 +1,32 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+#if !NET
+
+using System.Runtime.CompilerServices;
+
+namespace System;
+
+internal static class CharExtensions
+{
+ extension(char)
+ {
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static bool IsAsciiDigit(char value) =>
+ value is >= '0' and <= '9';
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static bool IsAsciiLetterOrDigit(char value) =>
+ value is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') or (>= '0' and <= '9');
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static bool IsAsciiLetterLower(char value) =>
+ value is >= 'a' and <= 'z';
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static bool IsAsciiLetterUpper(char value) =>
+ value is >= 'A' and <= 'Z';
+ }
+}
+
+#endif
diff --git a/test/OpenTelemetry.Extensions.Benchmarks/ConsistentProbabilitySamplerBenchmarks.cs b/test/OpenTelemetry.Extensions.Benchmarks/ConsistentProbabilitySamplerBenchmarks.cs
new file mode 100644
index 0000000000..2d7dde0a04
--- /dev/null
+++ b/test/OpenTelemetry.Extensions.Benchmarks/ConsistentProbabilitySamplerBenchmarks.cs
@@ -0,0 +1,66 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Diagnostics;
+using BenchmarkDotNet.Attributes;
+using OpenTelemetry.Trace;
+
+namespace OpenTelemetry.Extensions.Benchmarks;
+
+///
+/// Benchmarks the per-span cost of across
+/// the different sources it can use to resolve the trace randomness value.
+///
+[MemoryDiagnoser(displayGenColumns: false)]
+public class ConsistentProbabilitySamplerBenchmarks
+{
+ private readonly Sampler sampler = new ConsistentProbabilitySampler(0.25);
+
+ private SamplingParameters rootSpan;
+ private SamplingParameters explicitRandomValue;
+ private SamplingParameters randomTraceId;
+ private SamplingParameters explicitRandomValueWithOtherMembers;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ var traceId = ActivityTraceId.CreateRandom();
+ var spanId = ActivitySpanId.CreateRandom();
+
+ // A root span with no incoming tracestate, so the randomness comes from the TraceId.
+ this.rootSpan = CreateParameters(default, traceId);
+
+ // A child that inherits an explicit rv value from its parent.
+ this.explicitRandomValue = CreateParameters(
+ new ActivityContext(traceId, spanId, ActivityTraceFlags.Recorded, "ot=rv:6e6d1a75832a2f"),
+ traceId);
+
+ // A child whose parent sets the W3C "random" trace flag (0x2) but no explicit rv, so the
+ // randomness is taken from the TraceId.
+ // https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/3867
+ // will change this code to use ActivityTraceFlags.RandomTraceId.
+ this.randomTraceId = CreateParameters(
+ new ActivityContext(traceId, spanId, (ActivityTraceFlags)0x3),
+ traceId);
+
+ // A child with an explicit rv value alongside other tracestate members that must be preserved.
+ this.explicitRandomValueWithOtherMembers = CreateParameters(
+ new ActivityContext(traceId, spanId, ActivityTraceFlags.Recorded, "ot=rv:6e6d1a75832a2f,vendorone=abc,vendortwo=def"),
+ traceId);
+ }
+
+ [Benchmark(Baseline = true)]
+ public SamplingResult RootSpan() => this.sampler.ShouldSample(in this.rootSpan);
+
+ [Benchmark]
+ public SamplingResult ExplicitRandomValue() => this.sampler.ShouldSample(in this.explicitRandomValue);
+
+ [Benchmark]
+ public SamplingResult RandomTraceId() => this.sampler.ShouldSample(in this.randomTraceId);
+
+ [Benchmark]
+ public SamplingResult ExplicitRandomValueWithOtherMembers() => this.sampler.ShouldSample(in this.explicitRandomValueWithOtherMembers);
+
+ private static SamplingParameters CreateParameters(ActivityContext parentContext, ActivityTraceId traceId)
+ => new(parentContext, traceId, "operation", ActivityKind.Internal, tags: null, links: null);
+}
diff --git a/test/OpenTelemetry.Extensions.Benchmarks/OpenTelemetry.Extensions.Benchmarks.csproj b/test/OpenTelemetry.Extensions.Benchmarks/OpenTelemetry.Extensions.Benchmarks.csproj
new file mode 100644
index 0000000000..003a2ffaf3
--- /dev/null
+++ b/test/OpenTelemetry.Extensions.Benchmarks/OpenTelemetry.Extensions.Benchmarks.csproj
@@ -0,0 +1,19 @@
+
+
+
+ $(SupportedNetTargets)
+ $(TargetFrameworks);$(NetFrameworkMinimumSupportedVersion)
+ Exe
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/OpenTelemetry.Extensions.Benchmarks/Program.cs b/test/OpenTelemetry.Extensions.Benchmarks/Program.cs
new file mode 100644
index 0000000000..2735a18875
--- /dev/null
+++ b/test/OpenTelemetry.Extensions.Benchmarks/Program.cs
@@ -0,0 +1,7 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using BenchmarkDotNet.Running;
+
+var summaries = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
+return summaries.SelectMany(p => p.Reports).Any((p) => !p.Success) ? 1 : 0;
diff --git a/test/OpenTelemetry.Extensions.Benchmarks/README.md b/test/OpenTelemetry.Extensions.Benchmarks/README.md
new file mode 100644
index 0000000000..9583b6f8ef
--- /dev/null
+++ b/test/OpenTelemetry.Extensions.Benchmarks/README.md
@@ -0,0 +1,15 @@
+# OpenTelemetry.Extensions.Benchmarks
+
+This project contains benchmarks for the OpenTelemetry .NET SDK preview features
+and extensions.
+
+## Running the Benchmarks
+
+To run all benchmarks:
+
+```bash
+dotnet run --configuration Release --framework net10.0 --project test\OpenTelemetry.Extensions.Benchmarks
+```
+
+Then choose the benchmark class that you want to run by entering the required
+option number from the list of options shown on the Console window.
diff --git a/test/OpenTelemetry.Extensions.FuzzTests/ConsistentProbabilityFuzzTests.cs b/test/OpenTelemetry.Extensions.FuzzTests/ConsistentProbabilityFuzzTests.cs
new file mode 100644
index 0000000000..6d6051ec86
--- /dev/null
+++ b/test/OpenTelemetry.Extensions.FuzzTests/ConsistentProbabilityFuzzTests.cs
@@ -0,0 +1,218 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Diagnostics;
+using System.Globalization;
+using FsCheck;
+using FsCheck.Xunit;
+using OpenTelemetry.Extensions.Internal;
+using OpenTelemetry.Trace;
+
+namespace OpenTelemetry.Extensions;
+
+///
+/// Property-based (fuzz) tests that assert invariants of the consistent probability sampler and its
+/// supporting codec hold across a large number of randomized inputs.
+///
+public static class ConsistentProbabilityFuzzTests
+{
+ private const int MaxValue = 1_000;
+
+ [Property(MaxTest = MaxValue)]
+ public static void EncodeThreshold_ProducesValidRoundTrippableThreshold(double rawProbability, PositiveInt rawPrecision)
+ {
+ var probability = ToProbability(rawProbability);
+ var precision = ((rawPrecision.Get - 1) % 13) + 1; // 1 to 13.
+
+ var encoded = ConsistentProbability.EncodeThreshold(probability, precision);
+
+ // A th value is 1 to 14 lowercase hexadecimal digits.
+ Assert.InRange(encoded.Length, 1, ConsistentProbability.MaxHexDigits);
+ Assert.All(encoded, c => Assert.True(c is (>= '0' and <= '9') or (>= 'a' and <= 'f')));
+
+ // The decoded threshold is a valid 56-bit value and the encoding is a stable fixed point.
+ var threshold = ConsistentProbability.DecodeThreshold(encoded);
+ Assert.InRange(threshold, 0L, ConsistentProbability.MaxRandomValue);
+ Assert.Equal(encoded, ConsistentProbability.EncodeThresholdInteger(threshold));
+ }
+
+ [Property(MaxTest = MaxValue)]
+ public static void EncodeThresholdInteger_And_DecodeThreshold_RoundTrip(ulong rawThreshold)
+ {
+ var threshold = (long)(rawThreshold % (ulong)ConsistentProbability.MaxAdjustedCount); // [0, 2^56).
+
+ var encoded = ConsistentProbability.EncodeThresholdInteger(threshold);
+
+ Assert.InRange(encoded.Length, 1, ConsistentProbability.MaxHexDigits);
+ Assert.Equal(threshold, ConsistentProbability.DecodeThreshold(encoded));
+ }
+
+ [Property(MaxTest = MaxValue)]
+ public static void TryParseHex56_NeverThrowsAndStaysInRange(string? input)
+ {
+ // A throw would fail the property; the parser must tolerate any input.
+ var parsed = ConsistentProbability.TryParseHex56(input, out var value);
+
+ if (parsed)
+ {
+ Assert.InRange(input!.Length, 1, ConsistentProbability.MaxHexDigits);
+ Assert.InRange(value, 0L, ConsistentProbability.MaxRandomValue);
+ }
+ else
+ {
+ Assert.Equal(0L, value);
+ }
+ }
+
+ [Property(MaxTest = MaxValue)]
+ public static void OtelTraceState_ParseAndSerialize_AreIdempotent(string? input)
+ {
+ // Parsing arbitrary input must never throw.
+ var first = OtelTraceState.Parse(input);
+ var serialized = first.Serialize();
+
+ // Re-parsing the serialized form preserves the th/rv semantics...
+ var second = OtelTraceState.Parse(serialized);
+
+ Assert.Equal(first.HasThreshold, second.HasThreshold);
+ Assert.Equal(first.HasRandomValue, second.HasRandomValue);
+ Assert.Equal(first.Threshold, second.Threshold);
+ Assert.Equal(first.RandomValue, second.RandomValue);
+
+ // ...and serialization is a stable fixed point.
+ Assert.Equal(serialized, second.Serialize());
+ }
+
+ [Property(MaxTest = MaxValue)]
+ public static void ShouldSample_DecisionMatchesResolvedRandomness(double rawProbability, ulong rawRandomness, byte modeSelector, bool recorded)
+ {
+ var probability = ToProbability(rawProbability);
+ var expectedThreshold = ConsistentProbability.DecodeThreshold(
+ ConsistentProbability.EncodeThreshold(probability, ConsistentProbability.DefaultPrecision));
+
+ // 0 = explicit rv, 1 = random TraceId flag, 2 = presumed TraceId randomness.
+ var mode = modeSelector % 3;
+ var explicitRandomness = (long)(rawRandomness % (ulong)ConsistentProbability.MaxAdjustedCount);
+
+ var traceId = ActivityTraceId.CreateRandom();
+ _ = ConsistentProbability.TryParseHex56(traceId.ToHexString().AsSpan(18), out var traceIdRandomness);
+
+ var traceState = mode == 0 ? "ot=rv:" + Hex14(explicitRandomness) : null;
+
+ var flags = ActivityTraceFlags.None;
+ if (mode == 1)
+ {
+ // https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/3867
+ // will change this code to use ActivityTraceFlags.RandomTraceId.
+ flags |= (ActivityTraceFlags)0x2;
+ }
+
+ if (recorded)
+ {
+ flags |= ActivityTraceFlags.Recorded;
+ }
+
+ var parent = new ActivityContext(traceId, ActivitySpanId.CreateRandom(), flags, traceState);
+ var parameters = new SamplingParameters(parent, traceId, "operation", ActivityKind.Internal, tags: null, links: null);
+
+ var result = new ConsistentProbabilitySampler(probability).ShouldSample(in parameters);
+
+ // The output must always be parseable.
+ var outgoing = OtelTraceState.Parse(result.TraceStateString);
+
+ var randomness = mode == 0 ? explicitRandomness : traceIdRandomness;
+
+ if (mode != 0)
+ {
+ // Only a value propagated in the incoming context can appear in the outgoing one; the
+ // sampler never invents randomness of its own.
+ Assert.False(outgoing.HasRandomValue, "The sampler should not add an rv value.");
+ }
+
+ var expected = randomness >= expectedThreshold ? SamplingDecision.RecordAndSample : SamplingDecision.Drop;
+
+ Assert.Equal(expected, result.Decision);
+
+ if (expected == SamplingDecision.RecordAndSample)
+ {
+ Assert.True(outgoing.HasThreshold);
+ Assert.Equal(expectedThreshold, outgoing.Threshold);
+ }
+ else
+ {
+ Assert.False(outgoing.HasThreshold);
+ }
+ }
+
+ [Property(MaxTest = MaxValue)]
+ public static void ShouldSample_IsDeterministicForTheSameContext(double rawProbability, bool randomFlag, bool recorded)
+ {
+ // Two participants that observe the same context, without an explicit rv value to share,
+ // must resolve the same randomness and therefore reach the same decision.
+ var probability = ToProbability(rawProbability);
+
+ var flags = ActivityTraceFlags.None;
+
+ if (randomFlag)
+ {
+ // https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/3867
+ // will change this code to use ActivityTraceFlags.RandomTraceId.
+ flags |= (ActivityTraceFlags)0x2;
+ }
+
+ if (recorded)
+ {
+ flags |= ActivityTraceFlags.Recorded;
+ }
+
+ var traceId = ActivityTraceId.CreateRandom();
+ var parent = new ActivityContext(traceId, ActivitySpanId.CreateRandom(), flags);
+ var parameters = new SamplingParameters(parent, traceId, "operation", ActivityKind.Internal, tags: null, links: null);
+
+ var first = new ConsistentProbabilitySampler(probability).ShouldSample(in parameters);
+ var second = new ConsistentProbabilitySampler(probability).ShouldSample(in parameters);
+
+ Assert.Equal(first.Decision, second.Decision);
+ Assert.Equal(first.TraceStateString, second.TraceStateString);
+ Assert.False(OtelTraceState.Parse(first.TraceStateString).HasRandomValue);
+ }
+
+ [Property(MaxTest = MaxValue)]
+ public static void ShouldSample_IsMonotonicInProbabilityForSharedRandomness(double rawProbabilityA, double rawProbabilityB, ulong rawRandomness)
+ {
+ var lower = Math.Min(ToProbability(rawProbabilityA), ToProbability(rawProbabilityB));
+ var higher = Math.Max(ToProbability(rawProbabilityA), ToProbability(rawProbabilityB));
+
+ // A fixed, shared randomness value makes the decisions comparable across probabilities.
+ var randomness = (long)(rawRandomness % (ulong)ConsistentProbability.MaxAdjustedCount);
+ var traceState = "ot=rv:" + Hex14(randomness);
+ var parent = new ActivityContext(ActivityTraceId.CreateRandom(), ActivitySpanId.CreateRandom(), ActivityTraceFlags.None, traceState);
+ var parameters = new SamplingParameters(parent, ActivityTraceId.CreateRandom(), "operation", ActivityKind.Internal, tags: null, links: null);
+
+ var lowerDecision = new ConsistentProbabilitySampler(lower).ShouldSample(in parameters).Decision;
+ var higherDecision = new ConsistentProbabilitySampler(higher).ShouldSample(in parameters).Decision;
+
+ // A span kept at the lower probability must also be kept at the higher probability.
+ if (lowerDecision == SamplingDecision.RecordAndSample)
+ {
+ Assert.Equal(SamplingDecision.RecordAndSample, higherDecision);
+ }
+ }
+
+ private static double ToProbability(double value)
+ {
+ if (double.IsNaN(value) || double.IsInfinity(value))
+ {
+ return 1.0;
+ }
+
+ // Map any finite double into the valid (0, 1] range so the constructor never rejects it.
+ value = Math.Abs(value);
+ var fraction = value - Math.Floor(value); // [0, 1).
+ var probability = fraction == 0.0 ? 1.0 : fraction;
+
+ return Math.Min(1.0, Math.Max(Math.Pow(2, -56), probability));
+ }
+
+ private static string Hex14(long value) => value.ToString("x14", CultureInfo.InvariantCulture);
+}
diff --git a/test/OpenTelemetry.Extensions.FuzzTests/OpenTelemetry.Extensions.FuzzTests.csproj b/test/OpenTelemetry.Extensions.FuzzTests/OpenTelemetry.Extensions.FuzzTests.csproj
new file mode 100644
index 0000000000..8dd3a975db
--- /dev/null
+++ b/test/OpenTelemetry.Extensions.FuzzTests/OpenTelemetry.Extensions.FuzzTests.csproj
@@ -0,0 +1,12 @@
+
+
+
+ $(SupportedNetTargets)
+ $(TargetFrameworks);$(NetFrameworkMinimumSupportedVersion)
+
+
+
+
+
+
+
diff --git a/test/OpenTelemetry.Extensions.Tests/OpenTelemetry.Extensions.Tests.csproj b/test/OpenTelemetry.Extensions.Tests/OpenTelemetry.Extensions.Tests.csproj
index 9aa5fd7a35..ffa76b567e 100644
--- a/test/OpenTelemetry.Extensions.Tests/OpenTelemetry.Extensions.Tests.csproj
+++ b/test/OpenTelemetry.Extensions.Tests/OpenTelemetry.Extensions.Tests.csproj
@@ -12,6 +12,7 @@
+
diff --git a/test/OpenTelemetry.Extensions.Tests/Trace/ConsistentProbabilitySamplerTests.cs b/test/OpenTelemetry.Extensions.Tests/Trace/ConsistentProbabilitySamplerTests.cs
new file mode 100644
index 0000000000..206382bc3a
--- /dev/null
+++ b/test/OpenTelemetry.Extensions.Tests/Trace/ConsistentProbabilitySamplerTests.cs
@@ -0,0 +1,645 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Diagnostics;
+using System.Diagnostics.Tracing;
+using System.Globalization;
+using OpenTelemetry.Context.Propagation;
+using OpenTelemetry.Extensions.Internal;
+using OpenTelemetry.Tests;
+using OpenTelemetry.Trace;
+
+namespace OpenTelemetry.Extensions.Tests.Trace;
+
+public class ConsistentProbabilitySamplerTests
+{
+ // 0x02 is the W3C Trace Context "random" flag.
+ // https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/3867
+ // will change this code to use ActivityTraceFlags.RandomTraceId.
+ private const ActivityTraceFlags RandomTraceIdFlag = (ActivityTraceFlags)0x02;
+
+ [Theory]
+ [InlineData(double.NaN)]
+ [InlineData(0.0)]
+ [InlineData(-0.5)]
+ [InlineData(1.0000001)]
+ [InlineData(2.0)]
+ [InlineData(double.PositiveInfinity)]
+ public void Constructor_ThrowsArgumentOutOfRangeException_WhenProbabilityIsInvalid(double samplingProbability)
+ {
+ var exception = Assert.Throws(() => new ConsistentProbabilitySampler(samplingProbability));
+
+ Assert.Equal("samplingProbability", exception.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_ThrowsArgumentOutOfRangeException_WhenProbabilityIsSmallerThanSmallestValidProbability()
+ {
+ var exception = Assert.Throws(() => new ConsistentProbabilitySampler(Math.Pow(2, -57)));
+
+ Assert.Equal("samplingProbability", exception.ParamName);
+ }
+
+ [Theory]
+ [InlineData(1.0)]
+ [InlineData(0.5)]
+ [InlineData(0.0001)]
+ public void Constructor_AcceptsValidProbability(double samplingProbability)
+ {
+ var sampler = new ConsistentProbabilitySampler(samplingProbability);
+
+ Assert.NotNull(sampler);
+ }
+
+ [Fact]
+ public void Constructor_AcceptsSmallestValidProbability()
+ {
+ var sampler = new ConsistentProbabilitySampler(Math.Pow(2, -56));
+
+ Assert.NotNull(sampler);
+ }
+
+ [Theory]
+ [InlineData(0.5, "ConsistentProbabilitySampler{0.5}")]
+ [InlineData(0.25, "ConsistentProbabilitySampler{0.25}")]
+ [InlineData(1.0, "ConsistentProbabilitySampler{1}")]
+ public void Description_DescribesTheProbability(double samplingProbability, string expected)
+ {
+ var sampler = new ConsistentProbabilitySampler(samplingProbability);
+
+ Assert.Equal(expected, sampler.Description);
+ }
+
+ [Theory]
+ [InlineData(0L)]
+ [InlineData(0x80000000000000L)]
+ [InlineData(0x00ffffffffffffffL)]
+ public void ShouldSample_AlwaysSamplesWhenProbabilityIsOne(long randomness)
+ {
+ var parameters = CreateRootParameters(randomness);
+ var sampler = new ConsistentProbabilitySampler(1.0);
+
+ var result = sampler.ShouldSample(in parameters);
+
+ Assert.Equal(SamplingDecision.RecordAndSample, result.Decision);
+ Assert.Equal("ot=th:0", result.TraceStateString);
+ }
+
+ [Fact]
+ public void ShouldSample_SamplesWhenRandomnessEqualsThreshold()
+ {
+ // At 50% the rejection threshold is exactly 2^55.
+ var parameters = CreateRootParameters(0x80000000000000L);
+ var sampler = new ConsistentProbabilitySampler(0.5);
+
+ var result = sampler.ShouldSample(in parameters);
+
+ Assert.Equal(SamplingDecision.RecordAndSample, result.Decision);
+ Assert.Equal("ot=th:8", result.TraceStateString);
+ }
+
+ [Fact]
+ public void ShouldSample_DropsWhenRandomnessBelowThreshold()
+ {
+ var parameters = CreateRootParameters(0x7fffffffffffffL);
+ var sampler = new ConsistentProbabilitySampler(0.5);
+
+ var result = sampler.ShouldSample(in parameters);
+
+ Assert.Equal(SamplingDecision.Drop, result.Decision);
+
+ // The threshold is erased for an unsampled span, and no randomness is added to the context.
+ Assert.Equal(string.Empty, result.TraceStateString);
+ }
+
+ [Fact]
+ public void ShouldSample_UsesExplicitRandomValueInsteadOfTraceId()
+ {
+ // The TraceID randomness would drop the span (0), but the explicit rv is the maximum value.
+ var traceId = CreateTraceId(0L);
+ var parent = new ActivityContext(
+ traceId,
+ ActivitySpanId.CreateRandom(),
+ ActivityTraceFlags.None,
+ traceState: "ot=rv:ffffffffffffff");
+
+ var parameters = CreateParameters(parent, traceId);
+ var sampler = new ConsistentProbabilitySampler(0.5);
+
+ var result = sampler.ShouldSample(in parameters);
+
+ Assert.Equal(SamplingDecision.RecordAndSample, result.Decision);
+ Assert.Equal("ot=th:8;rv:ffffffffffffff", result.TraceStateString);
+ }
+
+ [Fact]
+ public void ShouldSample_DoesNotUseRandomValueFromMalformedOtEntry()
+ {
+ // The malformed pair invalidates the ot entry, so the zero-valued TraceID randomness is used
+ // instead of the otherwise valid-looking rv value.
+ var traceId = CreateTraceId(0L);
+ var parent = new ActivityContext(
+ traceId,
+ ActivitySpanId.CreateRandom(),
+ RandomTraceIdFlag,
+ traceState: "ot=rv:ffffffffffffff;malformed");
+
+ var parameters = CreateParameters(parent, traceId);
+ var sampler = new ConsistentProbabilitySampler(0.5);
+
+ var result = sampler.ShouldSample(in parameters);
+
+ Assert.Equal(SamplingDecision.Drop, result.Decision);
+ Assert.Equal(string.Empty, result.TraceStateString);
+ }
+
+ [Theory]
+ [InlineData("ffffffffffffff", SamplingDecision.RecordAndSample)]
+ [InlineData("00000000000000", SamplingDecision.Drop)]
+ public void ShouldSample_UsesTraceIdWhenRandomFlagIsSet(string trailing, SamplingDecision expected)
+ {
+ var traceId = ActivityTraceId.CreateFromString((new string('f', 18) + trailing).AsSpan());
+
+ var parent = new ActivityContext(traceId, ActivitySpanId.CreateRandom(), RandomTraceIdFlag);
+ var parameters = CreateParameters(parent, traceId);
+
+ var sampler = new ConsistentProbabilitySampler(0.5);
+
+ var result = sampler.ShouldSample(in parameters);
+
+ Assert.Equal(expected, result.Decision);
+
+ // Randomness comes from the TraceID, so no explicit rv is added either way.
+ Assert.Equal(expected == SamplingDecision.RecordAndSample ? "ot=th:8" : string.Empty, result.TraceStateString);
+ }
+
+ [Fact]
+ public void ShouldSample_UsesTraceIdForRootSpanWithoutAddingRandomValue()
+ {
+ // A root span could legitimately insert an explicit randomness value, but the TraceID travels
+ // with every participant in the trace whereas a tracestate entry may be stripped or truncated,
+ // so the TraceID is the more robust source of randomness.
+ var parameters = CreateRootParameters(0x90000000000000L);
+ var sampler = new ConsistentProbabilitySampler(0.5);
+
+ var result = sampler.ShouldSample(in parameters);
+
+ Assert.Equal(SamplingDecision.RecordAndSample, result.Decision);
+ Assert.Equal("ot=th:8", result.TraceStateString);
+ Assert.False(OtelTraceState.Parse(result.TraceStateString).HasRandomValue);
+ }
+
+ [Fact]
+ public void ShouldSample_DoesNotCreateRandomnessForNonRootSpanWithoutRandomFlag()
+ {
+ // "The Root sampling decision is the only case where it is permitted to modify the explicit
+ // trace randomness value for a Context." Two services that receive the same context without
+ // an rv value and without the random trace flag must therefore resolve the same randomness,
+ // otherwise their decisions for the same trace can disagree.
+ var traceId = ActivityTraceId.CreateRandom();
+ var parent = new ActivityContext(traceId, ActivitySpanId.CreateRandom(), ActivityTraceFlags.Recorded);
+ var parameters = CreateParameters(parent, traceId);
+
+ var first = new ConsistentProbabilitySampler(0.5).ShouldSample(in parameters);
+ var second = new ConsistentProbabilitySampler(0.5).ShouldSample(in parameters);
+
+ Assert.Equal(first.Decision, second.Decision);
+ Assert.Equal(first.TraceStateString, second.TraceStateString);
+
+ // The decision is the one implied by the TraceID, and no rv value was invented.
+ var expected = GetRandomness(traceId) >= 0x80000000000000L
+ ? SamplingDecision.RecordAndSample
+ : SamplingDecision.Drop;
+
+ Assert.Equal(expected, first.Decision);
+ Assert.False(OtelTraceState.Parse(first.TraceStateString).HasRandomValue);
+ }
+
+ [Fact]
+ public void ShouldSample_IgnoresUppercaseRandomValue()
+ {
+ // An rv value must be exactly 14 lower-case hexadecimal digits, so an uppercase value is not
+ // valid randomness and the TraceID is used instead.
+ var traceId = CreateTraceId(0L);
+ var parent = new ActivityContext(
+ traceId,
+ ActivitySpanId.CreateRandom(),
+ ActivityTraceFlags.None,
+ traceState: "ot=rv:FFFFFFFFFFFFFF");
+
+ var parameters = CreateParameters(parent, traceId);
+ var sampler = new ConsistentProbabilitySampler(0.5);
+
+ var result = sampler.ShouldSample(in parameters);
+
+ Assert.Equal(SamplingDecision.Drop, result.Decision);
+ Assert.Equal(string.Empty, result.TraceStateString);
+ }
+
+ [Fact]
+ public void ShouldSample_WarnsOnceWhenPresumingTraceIdRandomness()
+ {
+ // A probability that is unique to this test, so the warning can be attributed to this sampler
+ // even if another test writes the same event concurrently.
+ const double Probability = 0.123456;
+
+ using var listener = new InMemoryEventListener(OpenTelemetryExtensionsEventSource.Log, EventLevel.Warning);
+
+ var traceId = ActivityTraceId.CreateRandom();
+ var parent = new ActivityContext(traceId, ActivitySpanId.CreateRandom(), ActivityTraceFlags.Recorded);
+ var parameters = CreateParameters(parent, traceId);
+
+ var sampler = new ConsistentProbabilitySampler(Probability);
+
+ _ = sampler.ShouldSample(in parameters);
+ _ = sampler.ShouldSample(in parameters);
+
+ var warnings = listener.Events.Where(
+ p => p.EventId == 5 && p.Payload?.Count == 1 && Equals(p.Payload[0], sampler.Description));
+
+ // "To assist with this migration, the TraceIdRatioBased Sampler issues a warning statement
+ // the first time it presumes TraceID randomness for a Context where the Trace random flag is
+ // not set." Only the first of the two decisions warns.
+ Assert.Single(warnings);
+ }
+
+ [Fact]
+ public void ShouldSample_DoesNotWarnWhenRandomFlagIsSet()
+ {
+ const double Probability = 0.234567;
+
+ using var listener = new InMemoryEventListener(OpenTelemetryExtensionsEventSource.Log, EventLevel.Warning);
+
+ var traceId = ActivityTraceId.CreateRandom();
+ var parent = new ActivityContext(traceId, ActivitySpanId.CreateRandom(), RandomTraceIdFlag);
+ var parameters = CreateParameters(parent, traceId);
+
+ var sampler = new ConsistentProbabilitySampler(Probability);
+
+ _ = sampler.ShouldSample(in parameters);
+
+ Assert.DoesNotContain(
+ listener.Events,
+ p => p.EventId == 5 && p.Payload?.Count == 1 && Equals(p.Payload[0], sampler.Description));
+ }
+
+ [Fact]
+ public void ShouldSample_DoesNotWarnForRootSpan()
+ {
+ // A root span has no incoming context whose randomness could be in doubt: the TraceID was
+ // generated by this SDK.
+ const double Probability = 0.345678;
+
+ using var listener = new InMemoryEventListener(OpenTelemetryExtensionsEventSource.Log, EventLevel.Warning);
+
+ var parameters = CreateRootParameters();
+ var sampler = new ConsistentProbabilitySampler(Probability);
+
+ _ = sampler.ShouldSample(in parameters);
+
+ Assert.DoesNotContain(
+ listener.Events,
+ p => p.EventId == 5 && p.Payload?.Count == 1 && Equals(p.Payload[0], sampler.Description));
+ }
+
+ [Fact]
+ public void ShouldSample_WarnsOnceWhenThresholdDoesNotFitInTraceState()
+ {
+ const double Probability = 0.456789;
+
+ using var listener = new InMemoryEventListener(OpenTelemetryExtensionsEventSource.Log, EventLevel.Warning);
+
+ var value = new string('a', OtelTraceState.TraceStateSizeLimit - "foo:".Length);
+ var traceState = $"ot=foo:{value}";
+ var traceId = CreateTraceId(ConsistentProbability.MaxRandomValue);
+ var parent = new ActivityContext(
+ traceId,
+ ActivitySpanId.CreateRandom(),
+ RandomTraceIdFlag,
+ traceState: traceState);
+ var parameters = CreateParameters(parent, traceId);
+ var sampler = new ConsistentProbabilitySampler(Probability);
+
+ var first = sampler.ShouldSample(in parameters);
+ var second = sampler.ShouldSample(in parameters);
+
+ Assert.Equal(SamplingDecision.RecordAndSample, first.Decision);
+ Assert.Equal(traceState, first.TraceStateString);
+ Assert.Equal(traceState, second.TraceStateString);
+
+ var warnings = listener.Events.Where(
+ p => p.EventId == 6 && p.Payload?.Count == 1 && Equals(p.Payload[0], sampler.Description));
+
+ Assert.Single(warnings);
+ }
+
+ [Fact]
+ public void ShouldSample_PreservesOtherTraceStateMembers()
+ {
+ var parent = new ActivityContext(
+ ActivityTraceId.CreateRandom(),
+ ActivitySpanId.CreateRandom(),
+ ActivityTraceFlags.None,
+ traceState: "ot=rv:ffffffffffffff,vendor=abc");
+
+ var parameters = CreateParameters(parent);
+ var sampler = new ConsistentProbabilitySampler(0.5);
+
+ var result = sampler.ShouldSample(in parameters);
+
+ Assert.Equal("ot=th:8;rv:ffffffffffffff,vendor=abc", result.TraceStateString);
+ }
+
+ [Fact]
+ public void ShouldSample_IgnoresParentThresholdAndEncodesItsOwn()
+ {
+ // "A consistent probability sampling decision ignores the parent's sampling threshold (if
+ // any)." The parent was sampled at 50% (th:8), but this sampler applies its own 25% (th:c).
+ var parent = new ActivityContext(
+ ActivityTraceId.CreateRandom(),
+ ActivitySpanId.CreateRandom(),
+ ActivityTraceFlags.Recorded,
+ traceState: "ot=th:8;rv:ffffffffffffff");
+
+ var parameters = CreateParameters(parent);
+ var sampler = new ConsistentProbabilitySampler(0.25);
+
+ var result = sampler.ShouldSample(in parameters);
+
+ Assert.Equal(SamplingDecision.RecordAndSample, result.Decision);
+
+ // The outgoing threshold is this sampler's (th:c), not the parent's (th:8).
+ Assert.Equal("ot=th:c;rv:ffffffffffffff", result.TraceStateString);
+ }
+
+ [Fact]
+ public void ShouldSample_DropsIndependentlyOfParentThreshold()
+ {
+ // The parent was sampled at 100% (th:0), but an independent decision based on the shared
+ // randomness (R = 0) drops the span at 50%, rather than inheriting the parent's decision.
+ var parent = new ActivityContext(
+ ActivityTraceId.CreateRandom(),
+ ActivitySpanId.CreateRandom(),
+ ActivityTraceFlags.Recorded,
+ traceState: "ot=th:0;rv:00000000000000");
+
+ var parameters = CreateParameters(parent);
+ var sampler = new ConsistentProbabilitySampler(0.5);
+
+ var result = sampler.ShouldSample(in parameters);
+
+ Assert.Equal(SamplingDecision.Drop, result.Decision);
+
+ // The parent's threshold is erased because this span is not sampled here.
+ Assert.Equal("ot=rv:00000000000000", result.TraceStateString);
+ }
+
+ [Fact]
+ public void ShouldSample_IsConsistentAcrossProbabilities()
+ {
+ // A span kept at probability p1 must also be kept at any probability p2 >= p1, given the
+ // same randomness value.
+ const long Randomness = 0x90000000000000L; // ~56.25% into the range.
+ var parent = new ActivityContext(
+ ActivityTraceId.CreateRandom(),
+ ActivitySpanId.CreateRandom(),
+ ActivityTraceFlags.None,
+ traceState: FormattableString.Invariant($"ot=rv:{Randomness:x14}"));
+
+ var parameters = CreateParameters(parent);
+
+ Assert.Equal(SamplingDecision.RecordAndSample, Sample(0.5));
+ Assert.Equal(SamplingDecision.RecordAndSample, Sample(0.75));
+ Assert.Equal(SamplingDecision.RecordAndSample, Sample(1.0));
+
+ // A much lower probability drops it.
+ Assert.Equal(SamplingDecision.Drop, Sample(0.1));
+
+ SamplingDecision Sample(double probability)
+ => new ConsistentProbabilitySampler(probability).ShouldSample(in parameters).Decision;
+ }
+
+ [Fact]
+ public void ShouldSample_ApproximatesConfiguredProbabilityAcrossRandomTraceIds()
+ {
+ const int Iterations = 100_000;
+ const double Probability = 0.25;
+
+ var sampler = new ConsistentProbabilitySampler(Probability);
+
+ var sampled = 0;
+ for (var i = 0; i < Iterations; i++)
+ {
+ // Each iteration is a new root span, whose randomness is the trailing 56 bits of a newly
+ // generated (random) TraceID.
+ var parameters = CreateRootParameters();
+
+ if (sampler.ShouldSample(in parameters).Decision == SamplingDecision.RecordAndSample)
+ {
+ sampled++;
+ }
+ }
+
+ var fraction = (double)sampled / Iterations;
+
+ Assert.InRange(fraction, Probability - 0.01, Probability + 0.01);
+ }
+
+ [Fact]
+ public void ShouldSample_PropagatesRandomnessConsistentlyAcrossProcesses()
+ {
+ const double Probability = 0.5;
+
+ var producerSource = nameof(this.ShouldSample_PropagatesRandomnessConsistentlyAcrossProcesses) + ".Producer";
+ var carrier = new Dictionary();
+ ActivityContext rootContext;
+ long randomness;
+ bool expectedSampled;
+ string expectedTraceState;
+
+ // Process 1: a service starts a root span at 50%.
+ using (var provider = Sdk.CreateTracerProviderBuilder()
+ .AddSource(producerSource)
+ .SetSampler(new ConsistentProbabilitySampler(Probability))
+ .Build())
+ using (var source = new ActivitySource(producerSource))
+ using (var root = source.StartActivity("root", ActivityKind.Server))
+ {
+ Assert.NotNull(root);
+
+ // The randomness of the whole trace is the trailing 56 bits of the TraceID the SDK
+ // generated for the root span, so the expected decision follows from it.
+ randomness = GetRandomness(root.TraceId);
+ expectedSampled = IsSampled(Probability, randomness);
+ expectedTraceState = expectedSampled ? "ot=th:8" : string.Empty;
+
+ // The sampler encoded its threshold, and left the randomness to the TraceID rather than
+ // adding an rv sub-key. The tracestate, rather than Activity.Recorded, is what reflects
+ // this sampler's decision: any other ActivityListener in the process can record the
+ // Activity as well.
+ Assert.Equal(expectedTraceState, root.TraceStateString ?? string.Empty);
+
+ if (expectedSampled)
+ {
+ Assert.True(root.Recorded, "The root span was not recorded.");
+ }
+
+ rootContext = root.Context;
+
+ // Serialize the span context into W3C traceparent/tracestate headers, as when sending a
+ // request to another service.
+ var outwardPropagator = new TraceContextPropagator();
+ outwardPropagator.Inject(
+ new(root.Context, Baggage.Current),
+ carrier,
+ static (headers, key, value) => headers[key] = value);
+ }
+
+ // The randomness travelled on the wire in the traceparent header.
+ Assert.Equal(rootContext.TraceId.ToHexString(), carrier["traceparent"].Split('-')[1]);
+
+ if (expectedSampled)
+ {
+ Assert.Equal(expectedTraceState, carrier["tracestate"]);
+ }
+ else
+ {
+ Assert.False(carrier.ContainsKey("tracestate"), "An unsampled span should not emit a tracestate.");
+ }
+
+ // The wire boundary: a different process extracts the propagated context.
+ var inwardPropagator = new TraceContextPropagator();
+ var context = inwardPropagator.Extract(
+ default,
+ carrier,
+ static (headers, key) => headers.TryGetValue(key, out var value) ? [value] : []);
+
+ var remoteParent = context.ActivityContext;
+
+ Assert.True(remoteParent.IsRemote, "The extracted context is not marked as remote.");
+ Assert.Equal(rootContext.TraceId, remoteParent.TraceId);
+ Assert.Equal(rootContext.SpanId, remoteParent.SpanId);
+
+ // Process 2: a downstream service continues the trace from the received context. A real child
+ // span is created across the process boundary and joins the same trace.
+ var consumerSource = nameof(this.ShouldSample_PropagatesRandomnessConsistentlyAcrossProcesses) + ".Consumer";
+
+ using (var provider = Sdk.CreateTracerProviderBuilder()
+ .AddSource(consumerSource)
+ .SetSampler(new ConsistentProbabilitySampler(Probability))
+ .Build())
+ using (var source = new ActivitySource(consumerSource))
+ using (var child = source.StartActivity("child", ActivityKind.Server, remoteParent))
+ {
+ Assert.NotNull(child);
+ Assert.True(child.HasRemoteParent, "The child span does not have a remote parent.");
+ Assert.Equal(rootContext.TraceId, child.TraceId);
+ Assert.Equal(rootContext.SpanId, child.ParentSpanId);
+
+ // At the same probability the child decides consistently with the root, using the
+ // randomness that travelled in the TraceID.
+ Assert.Equal(expectedTraceState, child.TraceStateString ?? string.Empty);
+
+ if (expectedSampled)
+ {
+ Assert.True(child.Recorded, "The child span was not recorded.");
+ }
+ }
+
+ // The sampling decision made from the received context is driven by the propagated TraceID,
+ // and does not add randomness of its own.
+ var remoteParameters = new SamplingParameters(
+ remoteParent,
+ remoteParent.TraceId,
+ "child",
+ ActivityKind.Server);
+
+ var sampler = new ConsistentProbabilitySampler(Probability);
+ var remoteResult = sampler.ShouldSample(remoteParameters);
+
+ Assert.Equal(expectedSampled ? SamplingDecision.RecordAndSample : SamplingDecision.Drop, remoteResult.Decision);
+ Assert.Equal(expectedTraceState, remoteResult.TraceStateString);
+
+ // Consistency: kept at p1 implies kept at any p2 >= p1, while a lower probability
+ // that excludes this randomness consistently drops it.
+ Assert.Equal(ExpectedDecision(0.75), RemoteDecision(0.75));
+ Assert.Equal(ExpectedDecision(0.25), RemoteDecision(0.25));
+
+ SamplingDecision ExpectedDecision(double probability)
+ => IsSampled(probability, randomness) ? SamplingDecision.RecordAndSample : SamplingDecision.Drop;
+
+ SamplingDecision RemoteDecision(double probability)
+ => new ConsistentProbabilitySampler(probability).ShouldSample(remoteParameters).Decision;
+ }
+
+ [Fact]
+ public void ShouldSample_EncodesProbabilitiesNearOneExactly()
+ {
+ // 1 - 2^-8 = 0.99609375. The frexp(1 - probability) precision boost encodes this exactly as
+ // th:01 even at the default precision, where the reference float method would be coarse.
+ var parameters = CreateRootParameters(ConsistentProbability.MaxRandomValue);
+ var sampler = new ConsistentProbabilitySampler(1.0 - (1.0 / 256.0));
+
+ var result = sampler.ShouldSample(in parameters);
+
+ Assert.Equal(SamplingDecision.RecordAndSample, result.Decision);
+ Assert.Equal("ot=th:01", result.TraceStateString);
+ }
+
+ [Fact]
+ public void ShouldSample_UsesTrailingBytesOfTraceIdForRandomness()
+ {
+ // The leading 18 hex digits of the TraceID are ignored; only the trailing 14 (56 bits) are
+ // the randomness value, here 0xd29d6a7215ced0.
+ // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/6d20534d0a232acaa8cf7161ddbaeab6915e0c01/pkg/sampling/threshold_test.go#L64-L87
+ var traceId = ActivityTraceId.CreateFromString("abababababababababd29d6a7215ced0".AsSpan());
+
+ var parent = new ActivityContext(traceId, ActivitySpanId.CreateRandom(), RandomTraceIdFlag);
+ var parameters = CreateParameters(parent, traceId);
+
+ // 25% sampling has threshold "c" (0xc0000000000000); 0xd29d6a7215ced0 >= it, so sampled.
+ var sampler = new ConsistentProbabilitySampler(0.25);
+
+ var result = sampler.ShouldSample(in parameters);
+
+ Assert.Equal(SamplingDecision.RecordAndSample, result.Decision);
+ Assert.Equal("ot=th:c", result.TraceStateString);
+ }
+
+ private static bool IsSampled(double probability, long randomness)
+ {
+ var threshold = ConsistentProbability.DecodeThreshold(
+ ConsistentProbability.EncodeThreshold(probability, ConsistentProbability.DefaultPrecision));
+
+ return randomness >= threshold;
+ }
+
+ private static long GetRandomness(ActivityTraceId traceId)
+ {
+ var hex = traceId.ToHexString();
+
+ Assert.True(ConsistentProbability.TryParseHex56(hex.AsSpan(hex.Length - ConsistentProbability.MaxHexDigits), out var value));
+
+ return value;
+ }
+
+ private static ActivityTraceId CreateTraceId(long randomness)
+ {
+ var hex = new string('a', 32 - ConsistentProbability.MaxHexDigits) +
+ randomness.ToString("x14", CultureInfo.InvariantCulture);
+
+ return ActivityTraceId.CreateFromString(hex.AsSpan());
+ }
+
+ private static SamplingParameters CreateRootParameters()
+ => CreateParameters(default, ActivityTraceId.CreateRandom());
+
+ private static SamplingParameters CreateRootParameters(long randomness)
+ => CreateParameters(default, CreateTraceId(randomness));
+
+ private static SamplingParameters CreateParameters(ActivityContext parentContext)
+ => CreateParameters(parentContext, ActivityTraceId.CreateRandom());
+
+ private static SamplingParameters CreateParameters(ActivityContext parentContext, ActivityTraceId traceId)
+ => new(parentContext, traceId, "TestOperation", ActivityKind.Internal, tags: null, links: null);
+}
diff --git a/test/OpenTelemetry.Extensions.Tests/Trace/ConsistentProbabilityTests.cs b/test/OpenTelemetry.Extensions.Tests/Trace/ConsistentProbabilityTests.cs
new file mode 100644
index 0000000000..28bab5f50a
--- /dev/null
+++ b/test/OpenTelemetry.Extensions.Tests/Trace/ConsistentProbabilityTests.cs
@@ -0,0 +1,252 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using OpenTelemetry.Extensions.Internal;
+
+namespace OpenTelemetry.Extensions.Tests.Trace;
+
+public class ConsistentProbabilityTests
+{
+ // The worked example table from the specification, for 1-in-N probability sampling at
+ // precision 3, 4 and 5.
+ // https://opentelemetry.io/docs/specs/otel/trace/tracestate-probability-sampling/#converting-floating-point-probability-to-threshold-value
+ [Theory]
+
+ // 1-in-N, precision 3.
+ [InlineData(1, 3, "0")]
+ [InlineData(2, 3, "8")]
+ [InlineData(3, 3, "aab")]
+ [InlineData(4, 3, "c")]
+ [InlineData(5, 3, "ccd")]
+ [InlineData(8, 3, "e")]
+ [InlineData(10, 3, "e66")]
+ [InlineData(16, 3, "f")]
+ [InlineData(100, 3, "fd71")]
+ [InlineData(1000, 3, "ffbe7")]
+ [InlineData(10000, 3, "fff972")]
+ [InlineData(100000, 3, "ffff584")]
+ [InlineData(1000000, 3, "ffffef4")]
+
+ // 1-in-N, precision 4.
+ [InlineData(1, 4, "0")]
+ [InlineData(2, 4, "8")]
+ [InlineData(3, 4, "aaab")]
+ [InlineData(4, 4, "c")]
+ [InlineData(5, 4, "cccd")]
+ [InlineData(8, 4, "e")]
+ [InlineData(10, 4, "e666")]
+ [InlineData(16, 4, "f")]
+ [InlineData(100, 4, "fd70a")]
+ [InlineData(1000, 4, "ffbe77")]
+ [InlineData(10000, 4, "fff9724")]
+ [InlineData(100000, 4, "ffff583a")]
+ [InlineData(1000000, 4, "ffffef39")]
+
+ // 1-in-N, precision 5.
+ [InlineData(1, 5, "0")]
+ [InlineData(2, 5, "8")]
+ [InlineData(3, 5, "aaaab")]
+ [InlineData(4, 5, "c")]
+ [InlineData(5, 5, "ccccd")]
+ [InlineData(8, 5, "e")]
+ [InlineData(10, 5, "e6666")]
+ [InlineData(16, 5, "f")]
+ [InlineData(100, 5, "fd70a4")]
+ [InlineData(1000, 5, "ffbe76d")]
+ [InlineData(10000, 5, "fff97247")]
+ [InlineData(100000, 5, "ffff583a5")]
+ [InlineData(1000000, 5, "ffffef391")]
+ public void EncodeThreshold_MatchesSpecificationTable(int oneInN, int precision, string expected)
+ {
+ var probability = oneInN == 1 ? 1.0 : 1.0 / oneInN;
+
+ var actual = ConsistentProbability.EncodeThreshold(probability, precision);
+
+ Assert.Equal(expected, actual);
+ }
+
+ [Theory]
+ [InlineData(1.0)]
+ [InlineData(2.0)]
+ [InlineData(double.PositiveInfinity)]
+ public void EncodeThreshold_ReturnsZeroForProbabilityAtOrAboveOne(double probability)
+ => Assert.Equal("0", ConsistentProbability.EncodeThreshold(probability, ConsistentProbability.DefaultPrecision));
+
+ [Theory]
+ [InlineData(0.5, "8")]
+ [InlineData(0.25, "c")]
+ [InlineData(0.125, "e")]
+ [InlineData(0.0625, "f")]
+ public void EncodeThreshold_EncodesExactBinaryFractions(double probability, string expected)
+ {
+ // Exact binary fractions are precision-independent.
+ Assert.Equal(expected, ConsistentProbability.EncodeThreshold(probability, 3));
+ Assert.Equal(expected, ConsistentProbability.EncodeThreshold(probability, 4));
+ Assert.Equal(expected, ConsistentProbability.EncodeThreshold(probability, 13));
+ }
+
+ // Arbitrary-fraction cases verifying exact parity with the collector, including the full-precision
+ // rounding quirk where 1/3 ends in "c" rather than a repeating "a".
+ // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/6d20534d0a232acaa8cf7161ddbaeab6915e0c01/pkg/sampling/probability_test.go#L19-L58
+ [Theory]
+ [InlineData(2.0 / 3.0, 3, "555")]
+ [InlineData(1.0 / 3.0, 14, "aaaaaaaaaaaaac")]
+ public void EncodeThreshold_MatchesCollectorFractionExamples(double probability, int precision, string expected)
+ => Assert.Equal(expected, ConsistentProbability.EncodeThreshold(probability, precision));
+
+ // The collector's full-precision encoding examples, exercising exact encoding for probabilities
+ // near 1 (the frexp(1 - probability) precision boost) as well as near 0.
+ // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/6d20534d0a232acaa8cf7161ddbaeab6915e0c01/pkg/sampling/encoding_test.go#L61-L69
+ [Theory]
+ [InlineData(2.0 / 3.0, 14, "55555555555558")]
+ [InlineData(1.0 - (1.0 / 256.0), 14, "01")] // 1 - 0x1p-8, near 1.
+ [InlineData(1.0 - (84.0 / 256.0), 14, "54")] // 1 - 0x54p-8.
+ [InlineData(1.0 - 2.2204460492503131e-16, 14, "0000000000001")] // 1 - 2^-52, the closest probability below 1.
+ [InlineData(256.0 * 1.3877787807814457e-17, 14, "ffffffffffff")] // 0x100 * 2^-56 = 2^-48.
+ public void EncodeThreshold_MatchesCollectorEncodingExamples(double probability, int precision, string expected)
+ => Assert.Equal(expected, ConsistentProbability.EncodeThreshold(probability, precision));
+
+ [Fact]
+ public void ThresholdToProbability_MatchesCollectorExamples()
+ {
+ // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/6d20534d0a232acaa8cf7161ddbaeab6915e0c01/pkg/sampling/encoding_test.go#L103-L110
+ Assert.Equal(0.5, ConsistentProbability.ThresholdToProbability(ConsistentProbability.DecodeThreshold("8")));
+ Assert.Equal(1.0, ConsistentProbability.ThresholdToProbability(ConsistentProbability.DecodeThreshold("0")));
+ Assert.Equal(1.0 - (0x444 / 4096.0), ConsistentProbability.ThresholdToProbability(ConsistentProbability.DecodeThreshold("444")));
+ Assert.Equal(1.0 - (1.0 / 3.0), ConsistentProbability.ThresholdToProbability(ConsistentProbability.DecodeThreshold("55555554")), 9);
+ }
+
+ [Fact]
+ public void EncodeThreshold_EncodesVerySmallProbabilityExactly()
+ {
+ // The exact-integer encoding preserves the precise threshold for very small probabilities,
+ // where the floating-point reference method loses precision or rounds down towards "0".
+ const double Probability = 1e-15;
+
+ var encoded = ConsistentProbability.EncodeThreshold(Probability, ConsistentProbability.DefaultPrecision);
+
+ // 2^56 - round(1e-15 * 2^56) = 2^56 - 72 = 0xffffffffffffb8.
+ Assert.Equal("ffffffffffffb8", encoded);
+ }
+
+ // The specification's "very small" example (precision 3), verifying exact parity with the collector.
+ // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/6d20534d0a232acaa8cf7161ddbaeab6915e0c01/pkg/sampling/probability_test.go#L163-L188
+ [Theory]
+ [InlineData(1, "ffffffffffffff")] // Skip 1 out of 2^56.
+ [InlineData(2, "fffffffffffffe")] // Skip 2 out of 2^56.
+ [InlineData(3, "fffffffffffffd")]
+ [InlineData(4, "fffffffffffffc")]
+ [InlineData(8, "fffffffffffff8")]
+ [InlineData(16, "fffffffffffff")]
+ public void EncodeThreshold_EncodesSmallestProbabilitiesExactly(int numerator, string expected)
+ {
+ var probability = numerator * Math.Pow(2, -56);
+
+ Assert.Equal(expected, ConsistentProbability.EncodeThreshold(probability, 3));
+ }
+
+ [Theory]
+ [InlineData(1e-7)]
+ [InlineData(1e-9)]
+ [InlineData(1e-15)]
+ [InlineData(1.3877787807814457e-17)] // 2^-56, the smallest valid sampling probability.
+ public void EncodeThreshold_ProducesValidThresholdForSmallProbabilities(double probability)
+ {
+ // Very small probabilities drive the precision to its maximum (14), exercising that bound.
+ var encoded = ConsistentProbability.EncodeThreshold(probability, ConsistentProbability.DefaultPrecision);
+
+ // A th value is 1 to 14 lowercase hexadecimal digits.
+ Assert.InRange(encoded.Length, 1, ConsistentProbability.MaxHexDigits);
+ Assert.All(encoded, c => Assert.True(c is (>= '0' and <= '9') or (>= 'a' and <= 'f')));
+
+ // The decoded threshold stays within the valid 56-bit range and round-trips.
+ var threshold = ConsistentProbability.DecodeThreshold(encoded);
+ Assert.InRange(threshold, 1, ConsistentProbability.MaxRandomValue);
+ Assert.Equal(encoded, ConsistentProbability.EncodeThresholdInteger(threshold));
+ }
+
+ [Theory]
+ [InlineData(0L, "0")]
+ [InlineData(0x80000000000000L, "8")] // 50%
+ [InlineData(0xc0000000000000L, "c")] // 25%
+ [InlineData(0xfd70a400000000L, "fd70a4")] // ~1%
+ [InlineData(0x00ffffffffffffffL, "ffffffffffffff")]
+ public void EncodeThresholdInteger_RemovesTrailingZeros(long threshold, string expected)
+ => Assert.Equal(expected, ConsistentProbability.EncodeThresholdInteger(threshold));
+
+ [Theory]
+ [InlineData("0", 0L)]
+ [InlineData("8", 0x80000000000000L)] // "8" extended to 8000_0000_0000_00
+ [InlineData("c", 0xc0000000000000L)]
+ [InlineData("fd70a4", 0xfd70a400000000L)]
+ [InlineData("ffffffffffffff", 0x00ffffffffffffffL)]
+ public void DecodeThreshold_ExtendsWithTrailingZeros(string threshold, long expected)
+ => Assert.Equal(expected, ConsistentProbability.DecodeThreshold(threshold));
+
+ [Theory]
+ [InlineData("0")]
+ [InlineData("8")]
+ [InlineData("c")]
+ [InlineData("aaab")]
+ [InlineData("fd70a")]
+ [InlineData("fd70a4")]
+ [InlineData("ffffef391")]
+ public void EncodeAndDecodeThreshold_RoundTrips(string threshold)
+ {
+ var value = ConsistentProbability.DecodeThreshold(threshold);
+
+ Assert.Equal(threshold, ConsistentProbability.EncodeThresholdInteger(value));
+ }
+
+ // From https://opentelemetry.io/docs/specs/otel/trace/tracestate-handling/#sampling-threshold-value-th
+ [Theory]
+ [InlineData("0", 1.0, 1.0)] // 100% sampling.
+ [InlineData("8", 0.5, 2.0)] // 50% sampling.
+ [InlineData("c", 0.25, 4.0)] // 25% sampling.
+ public void Threshold_ConvertsToProbabilityAndAdjustedCount(string threshold, double probability, double adjustedCount)
+ {
+ var value = ConsistentProbability.DecodeThreshold(threshold);
+
+ Assert.Equal(probability, ConsistentProbability.ThresholdToProbability(value));
+ Assert.Equal(adjustedCount, ConsistentProbability.ThresholdToAdjustedCount(value));
+ }
+
+ // "Actual probability" and "Exact adjusted count" columns from the specification table (precision 5).
+ [Theory]
+ [InlineData("fd70a4", 0.009999990463256836, 100.00009536752259)]
+ [InlineData("ffbe76d", 0.000999998301267624, 1000.0016987352618)]
+ public void Threshold_MatchesSpecificationActualProbabilityAndAdjustedCount(string threshold, double probability, double adjustedCount)
+ {
+ var value = ConsistentProbability.DecodeThreshold(threshold);
+
+ Assert.Equal(probability, ConsistentProbability.ThresholdToProbability(value), 12);
+ Assert.Equal(adjustedCount, ConsistentProbability.ThresholdToAdjustedCount(value), 6);
+ }
+
+ [Theory]
+ [InlineData("0", 0L)]
+ [InlineData("8", 8L)]
+ [InlineData("f", 15L)]
+ [InlineData("ff", 255L)]
+ [InlineData("6e6d1a75832a2f", 0x6e6d1a75832a2fL)]
+ [InlineData("ffffffffffffff", 0x00ffffffffffffffL)]
+ public void TryParseHex56_ParsesValidValues(string value, long expected)
+ {
+ Assert.True(ConsistentProbability.TryParseHex56(value, out var actual));
+ Assert.Equal(expected, actual);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("g")] // Not a hexadecimal digit.
+ [InlineData("12345678901234x")]
+ [InlineData("123456789012345")] // 15 digits, exceeds 56 bits.
+ [InlineData("ABCDEF")] // The specification requires lowercase hexadecimal digits.
+ [InlineData("abcdeF")]
+ public void TryParseHex56_RejectsInvalidValues(string? value)
+ {
+ Assert.False(ConsistentProbability.TryParseHex56(value, out var actual));
+ Assert.Equal(0L, actual);
+ }
+}
diff --git a/test/OpenTelemetry.Extensions.Tests/Trace/OtelTraceStateTests.cs b/test/OpenTelemetry.Extensions.Tests/Trace/OtelTraceStateTests.cs
new file mode 100644
index 0000000000..c50064341a
--- /dev/null
+++ b/test/OpenTelemetry.Extensions.Tests/Trace/OtelTraceStateTests.cs
@@ -0,0 +1,269 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+using OpenTelemetry.Extensions.Internal;
+
+namespace OpenTelemetry.Extensions.Tests.Trace;
+
+public class OtelTraceStateTests
+{
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("vendor=value")] // No ot entry.
+ [InlineData("ot=foo:bar")] // No th or rv sub-keys.
+ public void Parse_WithoutThresholdOrRandomValue_HasNeither(string? traceState)
+ {
+ var state = OtelTraceState.Parse(traceState);
+
+ Assert.False(state.HasThreshold);
+ Assert.False(state.HasRandomValue);
+ }
+
+ [Theory]
+ [InlineData("ot=th:0", 0L)]
+ [InlineData("ot=th:8", 0x80000000000000L)] // 50%.
+ [InlineData("ot=th:c", 0xc0000000000000L)] // 25%.
+ [InlineData("ot=th:fd70a4", 0xfd70a400000000L)] // ~1%.
+ public void Parse_ReadsThreshold(string traceState, long expected)
+ {
+ var state = OtelTraceState.Parse(traceState);
+
+ Assert.True(state.HasThreshold);
+ Assert.Equal(expected, state.Threshold);
+ }
+
+ [Fact]
+ public void Parse_ReadsRandomValue()
+ {
+ // From https://opentelemetry.io/docs/specs/otel/trace/tracestate-handling/#explicit-randomness-value-rv
+ var state = OtelTraceState.Parse("ot=rv:6e6d1a75832a2f");
+
+ Assert.True(state.HasRandomValue);
+ Assert.Equal(0x6e6d1a75832a2fL, state.RandomValue);
+ }
+
+ [Fact]
+ public void Parse_ReadsThresholdAndRandomValue()
+ {
+ var state = OtelTraceState.Parse("ot=th:fd70a4;rv:6e6d1a75832a2f");
+
+ Assert.Equal(0xfd70a400000000L, state.Threshold);
+ Assert.Equal(0x6e6d1a75832a2fL, state.RandomValue);
+ }
+
+ [Theory]
+ [InlineData("ot=th:")] // Empty value.
+ [InlineData("ot=th:g")] // Not hexadecimal.
+ [InlineData("ot=th:123456789012345")] // 15 digits, exceeds 14.
+ [InlineData("ot=th:FD70A4")] // Uppercase, which the specification does not allow.
+ [InlineData("ot=th:fd70A4")] // Mixed case.
+ public void Parse_IgnoresInvalidThreshold(string traceState)
+ => Assert.False(OtelTraceState.Parse(traceState).HasThreshold);
+
+ [Theory]
+ [InlineData("ot=rv:6e6d1a75832a2")] // 13 digits.
+ [InlineData("ot=rv:6e6d1a75832a2ff")] // 15 digits.
+ [InlineData("ot=rv:6e6d1a75832axf")] // Not hexadecimal.
+ [InlineData("ot=rv:6E6D1A75832A2F")] // Uppercase, which the specification does not allow.
+ [InlineData("ot=rv:6e6d1a75832A2f")] // Mixed case.
+ public void Parse_IgnoresInvalidRandomValue(string traceState)
+ => Assert.False(OtelTraceState.Parse(traceState).HasRandomValue);
+
+ [Fact]
+ public void Parse_DoesNotPreserveInvalidRandomValue()
+ {
+ // "Values of rv MUST be exactly 14 lower-case hexadecimal digits", so an uppercase value is
+ // malformed and is erased rather than propagated onwards.
+ var state = OtelTraceState.Parse("ot=th:8;rv:6E6D1A75832A2F");
+
+ Assert.False(state.HasRandomValue);
+ Assert.Equal("ot=th:8", state.Serialize());
+ }
+
+ [Fact]
+ public void Serialize_EmitsThresholdWithTrailingZerosRemoved()
+ {
+ var state = default(OtelTraceState);
+ Assert.True(state.TrySetThreshold(0xfd70a400000000L));
+
+ Assert.Equal("ot=th:fd70a4", state.Serialize());
+ }
+
+ [Fact]
+ public void Serialize_EmitsRandomValueAs14Digits()
+ {
+ var state = default(OtelTraceState);
+ state.SetRandomValue(0x6e6d1a75832a2fL);
+
+ Assert.Equal("ot=rv:6e6d1a75832a2f", state.Serialize());
+ }
+
+ [Fact]
+ public void Serialize_EmitsThresholdBeforeRandomValue()
+ {
+ var state = default(OtelTraceState);
+ Assert.True(state.TrySetThreshold(0x80000000000000L));
+ state.SetRandomValue(0x6e6d1a75832a2fL);
+
+ Assert.Equal("ot=th:8;rv:6e6d1a75832a2f", state.Serialize());
+ }
+
+ [Fact]
+ public void Serialize_WithNothingToEmit_ReturnsEmptyString()
+ => Assert.Equal(string.Empty, default(OtelTraceState).Serialize());
+
+ [Fact]
+ public void ClearThreshold_RemovesThreshold()
+ {
+ var state = OtelTraceState.Parse("ot=th:8;rv:6e6d1a75832a2f");
+ state.ClearThreshold();
+
+ Assert.False(state.HasThreshold);
+ Assert.Equal("ot=rv:6e6d1a75832a2f", state.Serialize());
+ }
+
+ [Fact]
+ public void Serialize_PreservesOtherOtSubKeys()
+ {
+ var state = OtelTraceState.Parse("ot=th:8;foo:bar");
+
+ Assert.Equal("ot=th:8;foo:bar", state.Serialize());
+ }
+
+ [Fact]
+ public void Serialize_PreservesOtherTraceStateMembers()
+ {
+ var state = OtelTraceState.Parse("ot=th:8,vendor=value,other=123");
+
+ Assert.Equal("ot=th:8,vendor=value,other=123", state.Serialize());
+ }
+
+ [Fact]
+ public void Serialize_WhenAddingOtEntryAtMemberLimit_DropsRightmostMember()
+ {
+ var incomingMembers = Enumerable.Range(0, OtelTraceState.TraceStateMemberLimit)
+ .Select(static index => $"vendor{index}=value")
+ .ToArray();
+ var state = OtelTraceState.Parse(string.Join(",", incomingMembers));
+ Assert.True(state.TrySetThreshold(0x80000000000000L));
+
+ var outgoingMembers = state.Serialize().Split(',');
+
+ Assert.Equal(OtelTraceState.TraceStateMemberLimit, outgoingMembers.Length);
+ Assert.Equal("ot=th:8", outgoingMembers[0]);
+ Assert.Equal(incomingMembers.Take(OtelTraceState.TraceStateMemberLimit - 1), outgoingMembers.Skip(1));
+ Assert.DoesNotContain(incomingMembers[incomingMembers.Length - 1], outgoingMembers);
+ }
+
+ [Fact]
+ public void Serialize_WithoutOtEntry_PreservesOtherMembers()
+ {
+ var state = OtelTraceState.Parse("vendor=value");
+ Assert.True(state.TrySetThreshold(0x80000000000000L));
+
+ Assert.Equal("ot=th:8,vendor=value", state.Serialize());
+ }
+
+ [Fact]
+ public void Parse_DiscardsOtEntryThatExceedsTheSizeLimit()
+ {
+ var large = new string('a', OtelTraceState.TraceStateSizeLimit);
+ var state = OtelTraceState.Parse($"ot=th:8;foo:{large},vendor=value");
+
+ Assert.False(state.HasThreshold);
+ Assert.Equal("vendor=value", state.Serialize());
+ }
+
+ [Theory]
+ [InlineData("ot=th:0")]
+ [InlineData("ot=th:8;rv:6e6d1a75832a2f")]
+ [InlineData("ot=th:fd70a4;rv:6e6d1a75832a2f")]
+ public void ParseAndSerialize_RoundTrips(string traceState)
+ => Assert.Equal(traceState, OtelTraceState.Parse(traceState).Serialize());
+
+ [Fact]
+ public void Parse_IgnoresEmptyMembers()
+ {
+ var state = OtelTraceState.Parse(",vendor=value");
+
+ Assert.Equal("vendor=value", state.Serialize());
+ }
+
+ [Fact]
+ public void Parse_PreservesMalformedMemberVerbatim()
+ {
+ var state = OtelTraceState.Parse("malformed,vendor=value");
+
+ Assert.Equal("malformed,vendor=value", state.Serialize());
+ }
+
+ [Theory]
+ [InlineData("ot=;th:8")]
+ [InlineData("ot=malformed;th:8")]
+ [InlineData("ot=th:8;")]
+ [InlineData("ot=th:8;th:c")]
+ [InlineData("ot=rv:ffffffffffffff;rv:00000000000000")]
+ [InlineData("ot=foo:one;foo:two")]
+ [InlineData("ot=TH:8")]
+ [InlineData("ot=th:8;foo:contains spaces")]
+ public void Parse_DiscardsStructurallyInvalidOtEntry(string traceState)
+ {
+ var state = OtelTraceState.Parse($"{traceState},vendor=value");
+
+ Assert.False(state.HasThreshold);
+ Assert.False(state.HasRandomValue);
+ Assert.Equal("vendor=value", state.Serialize());
+ }
+
+ [Fact]
+ public void TrySetThreshold_WhenOtValueIsAtSizeLimit_PreservesOtherSubKeys()
+ {
+ var value = new string('a', OtelTraceState.TraceStateSizeLimit - "foo:".Length);
+ var traceState = $"ot=foo:{value}";
+ var state = OtelTraceState.Parse(traceState);
+
+ Assert.False(state.TrySetThreshold(0x80000000000000L));
+ Assert.False(state.HasThreshold);
+ Assert.Equal(traceState, state.Serialize());
+ }
+
+ [Fact]
+ public void TrySetThreshold_WhenReplacementWouldExceedSizeLimit_ErasesOldThreshold()
+ {
+ var value = new string('a', OtelTraceState.TraceStateSizeLimit - "th:0;foo:".Length);
+ var state = OtelTraceState.Parse($"ot=th:0;foo:{value}");
+
+ Assert.False(state.TrySetThreshold(ConsistentProbability.MaxRandomValue));
+ Assert.False(state.HasThreshold);
+ Assert.Equal($"ot=foo:{value}", state.Serialize());
+ }
+
+ [Fact]
+ public void Serialize_PreservesMultipleOtherSubKeys()
+ {
+ // Any number of unrecognized ot sub-keys are preserved; th is emitted first.
+ // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/6d20534d0a232acaa8cf7161ddbaeab6915e0c01/pkg/sampling/oteltracestate_test.go#L150-L263
+ var state = OtelTraceState.Parse("ot=e100:100;th:8;e101:101");
+
+ Assert.Equal("ot=th:8;e100:100;e101:101", state.Serialize());
+ }
+
+ [Fact]
+ public void ParseAndSerialize_PreservesUnusualSubKeyCharsetsAndEmptyValues()
+ {
+ // Sub-keys with mixed case, punctuation and empty values are preserved verbatim.
+ const string TraceState = "ot=x:0X1FFF;y:.-_-.;z:";
+
+ Assert.Equal(TraceState, OtelTraceState.Parse(TraceState).Serialize());
+ }
+
+ [Fact]
+ public void ParseAndSerialize_NormalizesThresholdTrailingZeros()
+ {
+ var state = OtelTraceState.Parse("ot=th:1000");
+
+ Assert.Equal(0x10000000000000L, state.Threshold);
+ Assert.Equal("ot=th:1", state.Serialize());
+ }
+}
diff --git a/test/OpenTelemetry.Extensions.Tests/Trace/RateLimitingSamplerTests.cs b/test/OpenTelemetry.Extensions.Tests/Trace/RateLimitingSamplerTests.cs
index afb24d518e..6f1e605f54 100644
--- a/test/OpenTelemetry.Extensions.Tests/Trace/RateLimitingSamplerTests.cs
+++ b/test/OpenTelemetry.Extensions.Tests/Trace/RateLimitingSamplerTests.cs
@@ -1,6 +1,7 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
+using System.Diagnostics;
using OpenTelemetry.Trace;
namespace OpenTelemetry.Extensions.Tests.Trace;
@@ -78,7 +79,7 @@ public async Task ShouldFilter_WhenAboveRateLimit()
var sampler = new RateLimitingSampler(SAMPLE_RATE);
int sampleIn = 0, sampleOut = 0;
- var startTime = DateTime.UtcNow;
+ var stopwatch = Stopwatch.StartNew();
for (var i = 0; i < CYCLES; i++)
{
@@ -103,15 +104,18 @@ public async Task ShouldFilter_WhenAboveRateLimit()
await Task.Delay(5);
}
- var timeTakenSeconds = (DateTime.UtcNow - startTime).TotalSeconds;
+ var timeTakenSeconds = stopwatch.Elapsed.TotalSeconds;
// Approximate the number of samples we should have taken
// Account for the fact that the initial balance is the SampleRate, so they will all be sampled in
var approxSamples = Math.Floor(timeTakenSeconds * SAMPLE_RATE) + SAMPLE_RATE;
- // Assert - We should have sampled in 5 traces per second over duration
- // Adding in a fudge factor
- Assert.InRange(sampleIn, approxSamples * 0.9, approxSamples * 1.1);
+ // Assert - We should have sampled in 5 traces per second over duration.
+ // Adding in a generous fudge factor (and a minimum absolute tolerance) to account for
+ // OS scheduler/timer jitter (particularly on CI runners), since the expected sample
+ // count is small enough that a purely percentage-based tolerance can be too tight.
+ var tolerance = Math.Max(approxSamples * 0.25, 3);
+ Assert.InRange(sampleIn, approxSamples - tolerance, approxSamples + tolerance);
Assert.Equal(sampleOut, CYCLES - sampleIn);
}
}