diff --git a/tracer/src/Datadog.Trace/Propagators/OtelTraceStateHelpers.cs b/tracer/src/Datadog.Trace/Propagators/OtelTraceStateHelpers.cs new file mode 100644 index 000000000000..d8da68381d93 --- /dev/null +++ b/tracer/src/Datadog.Trace/Propagators/OtelTraceStateHelpers.cs @@ -0,0 +1,173 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +#nullable enable + +using System; +using System.Collections.Generic; +using Datadog.Trace.Util; + +namespace Datadog.Trace.Propagators +{ + /// + /// String-surgery helpers over the raw content of the W3C tracestate "ot=" list-member + /// (OpenTelemetry consistent-probability-sampling sub-keys "rv"/"th"). The value is never + /// decoded into a typed struct: these two helpers are the only code that inspects or + /// rewrites the "rv"/"th" sub-keys; every other sub-key (recognized or not) round-trips + /// byte-for-byte through in its original order. + /// + internal static class OtelTraceStateHelpers + { + private const int MaxRvHexDigits = 14; + + /// + /// Finds the "rv" item in the raw "ot=" value (items separated by ';', key/value by ':') + /// and returns its value parsed as 1-14 lowercase hex digits, or null if absent or malformed. + /// Never throws. + /// + internal static ulong? ExtractRv(string? raw) + { + if (StringUtil.IsNullOrEmpty(raw)) + { + return null; + } + + foreach (var item in raw.Split(';')) + { + var colonIndex = item.IndexOf(':'); + + if (colonIndex <= 0 || colonIndex == item.Length - 1) + { + continue; + } + + var key = item.Substring(0, colonIndex); + + if (key != "rv") + { + continue; + } + + var value = item.Substring(colonIndex + 1); + return TryParseLowercaseHex(value, MaxRvHexDigits, out var rv) ? rv : null; + } + + return null; + } + + /// + /// Drops any existing "rv"/"th" items from (whether well-formed + /// or not), then emits "rv:<14-hex-digits>" (if is non-null) + /// followed by "th:<hex, trailing zero nibbles trimmed>" (if + /// is non-null), followed by every other item from in its original + /// order. Returns null when nothing is left to emit. + /// + internal static string? SetRvTh(string? raw, ulong? rv, ulong? th) + { + List? otherItems = null; + + if (!StringUtil.IsNullOrEmpty(raw)) + { + foreach (var item in raw!.Split(';')) + { + var colonIndex = item.IndexOf(':'); + var key = colonIndex > 0 ? item.Substring(0, colonIndex) : item; + + if (key is "rv" or "th") + { + continue; + } + + (otherItems ??= new List()).Add(item); + } + } + + if (rv is null && th is null && otherItems is null) + { + return null; + } + + var sb = StringBuilderCache.Acquire(); + + try + { + if (rv is { } rvValue) + { + sb.Append("rv:").Append(rvValue.ToString("x14")); + } + + if (th is { } thValue) + { + if (sb.Length > 0) + { + sb.Append(';'); + } + + sb.Append("th:").Append(FormatThresholdHex(thValue)); + } + + if (otherItems is not null) + { + foreach (var item in otherItems) + { + if (sb.Length > 0) + { + sb.Append(';'); + } + + sb.Append(item); + } + } + + return sb.Length == 0 ? null : StringBuilderCache.GetStringAndRelease(sb); + } + finally + { + StringBuilderCache.Release(sb); + } + } + + private static string FormatThresholdHex(ulong th) + { + // Format as hex (up to 14 hex digits for a 56-bit value), then trim trailing zero nibbles. + // A fully-zero threshold trims to the empty string; represent it as a single "0". + var hex = th.ToString("x"); + var trimmed = hex.TrimEnd('0'); + return trimmed.Length == 0 ? "0" : trimmed; + } + + private static bool TryParseLowercaseHex(string value, int maxDigits, out ulong result) + { + result = 0; + + if (value.Length == 0 || value.Length > maxDigits) + { + return false; + } + + foreach (var c in value) + { + int digit; + + if (c is >= '0' and <= '9') + { + digit = c - '0'; + } + else if (c is >= 'a' and <= 'f') + { + digit = c - 'a' + 10; + } + else + { + return false; + } + + result = (result << 4) | (uint)digit; + } + + return true; + } + } +} diff --git a/tracer/src/Datadog.Trace/Propagators/W3CTraceContextPropagator.cs b/tracer/src/Datadog.Trace/Propagators/W3CTraceContextPropagator.cs index f9e6eabedda1..d8f04411d13a 100644 --- a/tracer/src/Datadog.Trace/Propagators/W3CTraceContextPropagator.cs +++ b/tracer/src/Datadog.Trace/Propagators/W3CTraceContextPropagator.cs @@ -199,6 +199,21 @@ internal static string CreateTraceStateHeader(SpanContext context) sb.Length--; } + // OTel consistent-probability-sampling sub-keys ("ot=rv:...;th:..."), placed + // immediately after "dd=" so both survive right-side truncation of a crowded + // tracestate (W3C permits dropping members past 32). + var otelTraceState = context.OtelTraceState; + + if (!string.IsNullOrWhiteSpace(otelTraceState)) + { + if (sb.Length > 0) + { + sb.Append(TraceStateHeaderValuesSeparator); + } + + sb.Append("ot=").Append(otelTraceState); + } + var additionalState = context.AdditionalW3CTraceState; if (!string.IsNullOrWhiteSpace(additionalState)) @@ -311,17 +326,17 @@ internal static W3CTraceState ParseTraceState(string? header) // header format: "[*,]dd=s:1;o:rum;t.dm:-4;t.usr.id:12345[,*]" if (string.IsNullOrWhiteSpace(header)) { - return new W3CTraceState(samplingPriority: null, origin: null, lastParent: ZeroLastParent, propagatedTags: null, additionalValues: null); + return new W3CTraceState(samplingPriority: null, origin: null, lastParent: ZeroLastParent, propagatedTags: null, additionalValues: null, otTraceState: null); } - SplitTraceStateValues(header!, out var ddValues, out var additionalValues); + SplitTraceStateValues(header!, out var ddValues, out var otTraceState, out var additionalValues); - if (ddValues is null or { Length: < 6 }) + if (ddValues is null or { Length: < 3 }) { // "dd" section not found or it is too short - // shortest valid length is 6 as in "dd=a:b" + // shortest valid length is 3 as in "a:b" ("dd=" prefix already stripped) // note for this case the p will be viewed as 0 if added as a span tag - return new W3CTraceState(samplingPriority: null, origin: null, lastParent: ZeroLastParent, propagatedTags: null, additionalValues); + return new W3CTraceState(samplingPriority: null, origin: null, lastParent: ZeroLastParent, propagatedTags: null, additionalValues, otTraceState); } int? samplingPriority = null; @@ -331,8 +346,7 @@ internal static W3CTraceState ParseTraceState(string? header) try { - // skip "dd=" - var startIndex = 3; + var startIndex = 0; // name1:value1; // ^ endIndex @@ -449,7 +463,7 @@ internal static W3CTraceState ParseTraceState(string? header) lastParent ??= ZeroLastParent; - return new W3CTraceState(samplingPriority, origin, lastParent, propagatedTags, additionalValues); + return new W3CTraceState(samplingPriority, origin, lastParent, propagatedTags, additionalValues, otTraceState); } finally { @@ -457,91 +471,87 @@ internal static W3CTraceState ParseTraceState(string? header) } } - internal static void SplitTraceStateValues(string header, out string? ddValues, out string? additionalValues) + internal static void SplitTraceStateValues(string header, out string? ddValues, out string? otValues, out string? additionalValues) { - // header format: "[*,]dd=s:1;o:rum;t.dm:-4;t.usr.id:12345[,*]" - + // header format: "[*,]dd=s:1;o:rum;t.dm:-4;t.usr.id:12345[,ot=rv:...;th:...][,*]" if (string.IsNullOrWhiteSpace(header)) { ddValues = null; + otValues = null; additionalValues = null; return; } header = header.Trim(); - int ddStartIndex; - if (header.StartsWith("dd=", StringComparison.Ordinal)) + ExtractMember(header, "dd=", out ddValues, out var afterDd); + ExtractMember(afterDd, "ot=", out otValues, out var afterOt); + additionalValues = string.IsNullOrEmpty(afterOt) ? null : afterOt; + } + + // Finds the list-member with the given "=" prefix anywhere in a comma-separated + // tracestate value, returns its value (without the prefix) via "value", and returns + // every other member (still comma-separated, original order preserved) via "remainder". + private static void ExtractMember(string? header, string prefix, out string? value, out string? remainder) + { + if (string.IsNullOrEmpty(header)) { - ddStartIndex = 0; + value = null; + remainder = header; + return; + } + + int startIndex; + + if (header!.StartsWith(prefix, StringComparison.Ordinal)) + { + startIndex = 0; } else { - // if "dd=" is not at start of header, make sure we find the one preceded by comma - // in case there is something like "key1=valuedd=whatisthis,dd=..." - // ^ take this one - // ^ ignore this one - ddStartIndex = header.IndexOf(",dd=", StringComparison.Ordinal); + startIndex = header.IndexOf("," + prefix, StringComparison.Ordinal); - if (ddStartIndex >= 0) + if (startIndex >= 0) { - // if ",dd=" was found, skip the ',' - ddStartIndex++; + startIndex++; // skip the ',' } } - if (ddStartIndex < 0) + if (startIndex < 0) { - // "dd=" was not found in header, the entire header is "additional values" - // example tracestate: "foo=bar" - // ^^^^^^^ - ddValues = null; - additionalValues = header; + value = null; + remainder = header; return; } - // search for end of "dd=" - var ddEndIndex = header.IndexOf(TraceStateHeaderValuesSeparator, ddStartIndex + 3); + var endIndex = header.IndexOf(TraceStateHeaderValuesSeparator, startIndex + prefix.Length); - if (ddEndIndex < 0) + if (endIndex < 0) { - // "dd=" reaches the end of header - ddEndIndex = header.Length; + endIndex = header.Length; } - ddValues = header.Substring(ddStartIndex, ddEndIndex - ddStartIndex); + value = header.Substring(startIndex + prefix.Length, endIndex - startIndex - prefix.Length); - if (ddStartIndex == 0 && ddEndIndex == header.Length) + if (startIndex == 0 && endIndex == header.Length) { - // "dd" was the only key, no additional values - // example tracestate: "dd=s:1;o:rum" - additionalValues = null; + remainder = null; } - else if (ddStartIndex == 0) + else if (startIndex == 0) { - // "dd" first, additional values later - // example tracestate: "dd=s:1;o:rum,foo=bar" - // ^^^^^^^ - additionalValues = header.Substring(ddEndIndex + 1, header.Length - ddEndIndex - 1); + remainder = header.Substring(endIndex + 1, header.Length - endIndex - 1); } - else if (ddEndIndex == header.Length) + else if (endIndex == header.Length) { - // additional values first, "dd" later - // example tracestate: "foo=bar,dd=s:1;o:rum" - // ^^^^^^^ - additionalValues = header.Substring(0, ddStartIndex - 1); + remainder = header.Substring(0, startIndex - 1); } else { - // additional values on both sides, "dd" in the middle - // example tracestate: "foo1=bar1,dd=s:1;o:rum,foo2=bar2" => "foo1=bar1,foo2=bar2" - // ^^^^^^^^^ ^^^^^^^^^ - var otherValuesLeft = header.Substring(0, ddStartIndex - 1); - var otherValuesRight = header.Substring(ddEndIndex + 1, header.Length - ddEndIndex - 1); - - var sb = StringBuilderCache.Acquire(otherValuesLeft.Length + otherValuesRight.Length + 1); - sb.Append(otherValuesLeft).Append(TraceStateHeaderValuesSeparator).Append(otherValuesRight); - additionalValues = StringBuilderCache.GetStringAndRelease(sb); + var left = header.Substring(0, startIndex - 1); + var right = header.Substring(endIndex + 1, header.Length - endIndex - 1); + var sb = StringBuilderCache.Acquire(left.Length + right.Length + 1); + sb.Append(left).Append(TraceStateHeaderValuesSeparator).Append(right); + remainder = StringBuilderCache.GetStringAndRelease(sb); } } @@ -631,6 +641,7 @@ public bool TryExtract( spanContext.PropagatedTags = traceTags; spanContext.AdditionalW3CTraceState = traceState.AdditionalValues; + spanContext.OtelTraceState = traceState.OtTraceState; spanContext.LastParentId = traceState.LastParent; context = new PropagationContext(spanContext, baggage: null); diff --git a/tracer/src/Datadog.Trace/Propagators/W3CTraceState.cs b/tracer/src/Datadog.Trace/Propagators/W3CTraceState.cs index 1f7d4436f57e..a4fffdcd508f 100644 --- a/tracer/src/Datadog.Trace/Propagators/W3CTraceState.cs +++ b/tracer/src/Datadog.Trace/Propagators/W3CTraceState.cs @@ -18,15 +18,22 @@ internal readonly struct W3CTraceState // format is "_dd.p.key1:value1;_dd.p.key2:value2" public readonly string? PropagatedTags; - // the string left in "tracestate" after removing "dd=*" + // the string left in "tracestate" after removing "dd=*" and "ot=*" public readonly string? AdditionalValues; - public W3CTraceState(int? samplingPriority, string? origin, string? lastParent, string? propagatedTags, string? additionalValues) + /// + /// Raw content of the inbound "ot=" tracestate list-member (no "ot=" prefix), + /// captured verbatim with no sub-key parsing. Null if no "ot=" member was present. + /// + public readonly string? OtTraceState; + + public W3CTraceState(int? samplingPriority, string? origin, string? lastParent, string? propagatedTags, string? additionalValues, string? otTraceState = null) { SamplingPriority = samplingPriority; Origin = origin; LastParent = lastParent; PropagatedTags = propagatedTags; AdditionalValues = additionalValues; + OtTraceState = otTraceState; } } diff --git a/tracer/src/Datadog.Trace/Sampling/SamplingDecision.cs b/tracer/src/Datadog.Trace/Sampling/SamplingDecision.cs index 0f55b455ba43..277600710753 100644 --- a/tracer/src/Datadog.Trace/Sampling/SamplingDecision.cs +++ b/tracer/src/Datadog.Trace/Sampling/SamplingDecision.cs @@ -18,7 +18,8 @@ internal readonly struct SamplingDecision priority: SamplingPriorityValues.Default, mechanism: SamplingMechanism.Default, rate: null, - limiterRate: null); + limiterRate: null, + sample: null); public readonly int Priority; @@ -28,12 +29,21 @@ internal readonly struct SamplingDecision public readonly float? LimiterRate; - public SamplingDecision(int priority, string? mechanism, float? rate, float? limiterRate) + /// + /// The raw probability keep/drop outcome (before any rate-limiter demotion), or null + /// when no probability mechanism made this decision (e.g. ). + /// Used only to derive the OTel "ot.rv"/"ot.th" tracestate values in + /// — never affects . + /// + public readonly bool? Sample; + + public SamplingDecision(int priority, string? mechanism, float? rate, float? limiterRate, bool? sample = null) { Priority = priority; Mechanism = mechanism; Rate = rate; LimiterRate = limiterRate; + Sample = sample; } public void Deconstruct(out int priority, out string? mechanism, out float? rate, out float? limiterRate) diff --git a/tracer/src/Datadog.Trace/Sampling/TraceSampler.cs b/tracer/src/Datadog.Trace/Sampling/TraceSampler.cs index 269a3c719232..cf74da6a5a81 100644 --- a/tracer/src/Datadog.Trace/Sampling/TraceSampler.cs +++ b/tracer/src/Datadog.Trace/Sampling/TraceSampler.cs @@ -97,7 +97,7 @@ private SamplingDecision MakeSamplingDecision(Span span, float rate, string mech } } - return new SamplingDecision(priority, mechanism, rate, limiterRate); + return new SamplingDecision(priority, mechanism, rate, limiterRate, sample); } public sealed class Builder(IRateLimiter limiter) diff --git a/tracer/src/Datadog.Trace/SpanContext.cs b/tracer/src/Datadog.Trace/SpanContext.cs index 0eb636bd7800..cb42dba0274b 100644 --- a/tracer/src/Datadog.Trace/SpanContext.cs +++ b/tracer/src/Datadog.Trace/SpanContext.cs @@ -58,6 +58,9 @@ public sealed partial class SpanContext : ISpanContext, IReadOnlyDictionary /// Initializes a new instance of the class @@ -287,6 +290,27 @@ internal string AdditionalW3CTraceState } } + /// + /// Gets or sets the raw content of the inbound "ot=" W3C tracestate member + /// (OpenTelemetry consistent-probability-sampling sub-keys). Null if none was + /// present on extraction and nothing has derived one locally. + /// +#nullable enable + internal string? OtelTraceState + { + get => TraceContext?.OtelTraceState ?? _otelTraceState; + set + { + _otelTraceState = value; + + if (TraceContext is not null) + { + TraceContext.OtelTraceState = value; + } + } + } +#nullable restore + /// /// Gets or sets the last span ID of the most recently seen Datadog span that will be propagated downstream /// to allow for the re-parenting of spans in cases where spans in distributed traces have missing spans. diff --git a/tracer/src/Datadog.Trace/TraceContext.cs b/tracer/src/Datadog.Trace/TraceContext.cs index 71ee785e2cc9..e3e936b5526d 100644 --- a/tracer/src/Datadog.Trace/TraceContext.cs +++ b/tracer/src/Datadog.Trace/TraceContext.cs @@ -20,6 +20,7 @@ using Datadog.Trace.FeatureFlags; using Datadog.Trace.Iast; using Datadog.Trace.Logging; +using Datadog.Trace.Propagators; using Datadog.Trace.Sampling; using Datadog.Trace.SourceGenerators; using Datadog.Trace.Tagging; @@ -112,6 +113,15 @@ public Span? RootSpan /// internal string? AdditionalW3CTraceState { get; set; } + /// + /// Gets or sets the raw content of the inbound/rewritten W3C tracestate "ot=" member + /// (OpenTelemetry consistent-probability-sampling sub-keys), with no "ot=" prefix. + /// Null means there is nothing to emit. Never decoded into typed fields — see + /// for the only code that inspects + /// or rewrites its "rv"/"th" sub-keys. + /// + internal string? OtelTraceState { get; set; } + /// Gets the IAST context internal IastRequestContext? IastRequestContext => _iastRequestContext; @@ -318,7 +328,8 @@ public int GetOrMakeSamplingDecision(Span? span) samplingDecision.Priority, samplingDecision.Mechanism, samplingDecision.Rate, - samplingDecision.LimiterRate); + samplingDecision.LimiterRate, + sample: samplingDecision.Sample); return samplingDecision.Priority; } @@ -328,13 +339,16 @@ public void SetSamplingPriority( string? mechanism = null, float? rate = null, float? limiterRate = null, - bool notifyDistributedTracer = true) + bool notifyDistributedTracer = true, + bool? sample = null) { if (priority is not { } p) { return; } + var isLocalRoot = SamplingPriority is null; + // priority (keep/drop) can change (manually, ASM, etc) SamplingPriority = priority; @@ -357,15 +371,54 @@ public void SetSamplingPriority( Tags.RemoveTag(Trace.Tags.Propagated.DecisionMaker); } - // set Knuth sampling rate as a propagated tag for agent and rule-based sampling. - // use TryAddTag to preserve the original rate, consistent with AppliedSamplingRate ??= rate above. - if (rate is { } samplingRate && mechanism is Sampling.SamplingMechanism.AgentRate + // set Knuth sampling rate as a propagated tag for agent and rule-based sampling, + // and (for OTel interop) derive/erase the "ot=" tracestate rv/th sub-keys. + if (rate is { } samplingRate && samplingRate is >= 0f and <= 1f + && mechanism is Sampling.SamplingMechanism.AgentRate or Sampling.SamplingMechanism.LocalTraceSamplingRule or Sampling.SamplingMechanism.RemoteAdaptiveSamplingRule - or Sampling.SamplingMechanism.RemoteUserSamplingRule) + or Sampling.SamplingMechanism.RemoteUserSamplingRule + or Sampling.SamplingMechanism.Default) { // format with up to 6 decimal digits, no trailing zeros (per RFC) Tags.TryAddTag(Trace.Tags.Propagated.KnuthSamplingRate, samplingRate.ToString("0.######", CultureInfo.InvariantCulture)); + + if (isLocalRoot && sample is { } didSample && RootSpan is { } rootSpan) + { + var h = SamplingHelpers.ComputeKnuthHash(rootSpan.TraceId128.Lower); + var rv = (~h) >> 8; + // round-trip the rate through decimal first: samplingRate is a float, and widening it to + // double directly keeps its 32-bit mantissa noise in the low bits of the 56-bit threshold. + var th = (ulong)Math.Round((1.0 - (double)(decimal)samplingRate) * (1UL << 56), MidpointRounding.AwayFromZero); + + // clamp th into the valid 56-bit domain: rate=0.0f rounds up to 2^56, one bit out of range. + th = Math.Min(th, (1UL << 56) - 1); + + // 64<>56-bit imprecision clamp (design doc Decision 2 / RFC §7): + // force agreement between the (rv, th) pair and DD's actual keep/drop decision. + if (didSample && rv < th) + { + rv = th; + } + else if (!didSample && rv >= th) + { + rv = th > 0 ? th - 1 : 0; + } + + // overwrites any inherited rv/th outright; keeps unrecognized sub-keys. + OtelTraceState = OtelTraceStateHelpers.SetRvTh(OtelTraceState, rv, th); + + // rate-limiter demotion: probability decision said keep (didSample), but the + // final priority is a reject (the limiter is what changed the outcome) -> erase th. + if (didSample && SamplingPriorityValues.IsDrop(p)) + { + OtelTraceState = OtelTraceStateHelpers.SetRvTh(OtelTraceState, OtelTraceStateHelpers.ExtractRv(OtelTraceState), th: null); + } + } + } + else if (mechanism is Sampling.SamplingMechanism.Manual or Sampling.SamplingMechanism.Asm) + { + OtelTraceState = OtelTraceStateHelpers.SetRvTh(OtelTraceState, OtelTraceStateHelpers.ExtractRv(OtelTraceState), th: null); } if (notifyDistributedTracer) diff --git a/tracer/src/Datadog.Trace/Tracer.cs b/tracer/src/Datadog.Trace/Tracer.cs index abb35acec096..da8450bc655a 100644 --- a/tracer/src/Datadog.Trace/Tracer.cs +++ b/tracer/src/Datadog.Trace/Tracer.cs @@ -333,6 +333,7 @@ internal SpanContext CreateSpanContext(ISpanContext parent = null, string servic traceContext.SetSamplingPriority(samplingPriority); traceContext.Origin = parentSpanContext.Origin; traceContext.AdditionalW3CTraceState = parentSpanContext.AdditionalW3CTraceState; + traceContext.OtelTraceState = parentSpanContext.OtelTraceState; } // if the parent is a remote context, set the last parent id that came from the distributed header diff --git a/tracer/src/Datadog.Trace/Util/SamplingHelpers.cs b/tracer/src/Datadog.Trace/Util/SamplingHelpers.cs index fc2910ac4a0f..210c6aa8261c 100644 --- a/tracer/src/Datadog.Trace/Util/SamplingHelpers.cs +++ b/tracer/src/Datadog.Trace/Util/SamplingHelpers.cs @@ -41,9 +41,11 @@ internal static bool SampleByRate(ulong id, double rate) return false; } - return (id * KnuthFactor) <= (rate * ulong.MaxValue); + return ComputeKnuthHash(id) <= (rate * ulong.MaxValue); } + internal static ulong ComputeKnuthHash(ulong id) => id * KnuthFactor; + internal static bool IsKeptBySamplingPriority(in SpanCollection trace) { if (TraceContext.GetTraceContext(in trace)?.SamplingPriority is { } samplingPriority) diff --git a/tracer/test/Datadog.Trace.Tests/Propagators/MultiSpanContextPropagatorTests.cs b/tracer/test/Datadog.Trace.Tests/Propagators/MultiSpanContextPropagatorTests.cs index e93bb8cac067..7ae7a2403cd9 100644 --- a/tracer/test/Datadog.Trace.Tests/Propagators/MultiSpanContextPropagatorTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Propagators/MultiSpanContextPropagatorTests.cs @@ -424,6 +424,23 @@ public void Extract_Behavior_Ignore() result.Links.Should().BeNullOrEmpty(); } + [Fact] + public void Extract_Behavior_Ignore_DoesNotCarryOverOtelTraceState() + { + var headers = new Mock(); + + headers.Setup(h => h.GetValues("traceparent")) + .Returns(new[] { "00-000000000000000000000000075bcd15-000000003ade68b1-01" }); + headers.Setup(h => h.GetValues("tracestate")) + .Returns(new[] { "dd=s:1,ot=rv:ef284ace7a91e1;th:e6666666666668" }); + + var names = new[] { ContextPropagationHeaderStyle.W3CTraceContext }; + var ignorePropagator = SpanContextPropagatorFactory.GetSpanContextPropagator(names, names, propagationExtractFirst: true, ExtractBehavior.Ignore); + var result = ignorePropagator.Extract(headers.Object); + + result.SpanContext.Should().BeNull(); + } + [Fact] public void Extract_Behavior_Restart() { @@ -467,6 +484,24 @@ public void Extract_Behavior_Restart() opts => opts.ExcludingMissingMembers()); } + [Fact] + public void Extract_Behavior_Restart_DoesNotCarryOverOtelTraceState() + { + var headers = new Mock(); + + headers.Setup(h => h.GetValues("traceparent")) + .Returns(new[] { "00-000000000000000000000000075bcd15-000000003ade68b1-01" }); + headers.Setup(h => h.GetValues("tracestate")) + .Returns(new[] { "dd=s:1,ot=rv:ef284ace7a91e1;th:e6666666666668" }); + + var names = new[] { ContextPropagationHeaderStyle.W3CTraceContext }; + var restartPropagator = SpanContextPropagatorFactory.GetSpanContextPropagator(names, names, propagationExtractFirst: true, ExtractBehavior.Restart); + var result = restartPropagator.Extract(headers.Object); + + result.SpanContext.Should().BeNull(); + result.Links.Should().ContainSingle().Which.Context.OtelTraceState.Should().Be("rv:ef284ace7a91e1;th:e6666666666668"); + } + [Fact] public void Extract_B3SingleHeader_IHeadersCollection() { diff --git a/tracer/test/Datadog.Trace.Tests/Propagators/OtelTraceStateHelpersTests.cs b/tracer/test/Datadog.Trace.Tests/Propagators/OtelTraceStateHelpersTests.cs new file mode 100644 index 000000000000..4705183c53b8 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/Propagators/OtelTraceStateHelpersTests.cs @@ -0,0 +1,61 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +#nullable enable + +using Datadog.Trace.Propagators; +using FluentAssertions; +using Xunit; + +namespace Datadog.Trace.Tests.Propagators +{ + public class OtelTraceStateHelpersTests + { + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData("th:e6666666666668", null)] + [InlineData("rv:ef284ace7a91e1", 0xef284ace7a91e1UL)] + [InlineData("rv:ef284ace7a91e1;th:e6666666666668", 0xef284ace7a91e1UL)] + [InlineData("th:e6666666666668;rv:ef284ace7a91e1", 0xef284ace7a91e1UL)] + [InlineData("foo:bar;rv:1;baz:qux", 0x1UL)] + [InlineData("rv:zzzzzz", null)] // not hex -> malformed -> null + [InlineData("rv:123456789abcdef1", null)] // 15 hex digits -> too long -> malformed -> null + [InlineData("rv:", null)] // empty value -> malformed -> null + public void ExtractRv_ReturnsValueOrNull(string? raw, ulong? expected) + { + OtelTraceStateHelpers.ExtractRv(raw).Should().Be(expected); + } + + [Theory] + // no rv, no th, no other items -> null + [InlineData(null, null, null, null)] + [InlineData("", null, null, null)] + // rv only + [InlineData(null, 0xef284ace7a91e1UL, null, "rv:ef284ace7a91e1")] + // th only, no trailing zeros to trim + [InlineData(null, null, 0xe6666666666668UL, "th:e6666666666668")] + // both rv and th, rv first + [InlineData(null, 0xef284ace7a91e1UL, 0xe6666666666668UL, "rv:ef284ace7a91e1;th:e6666666666668")] + // th with trailing zero nibbles trimmed + [InlineData(null, null, 0x100UL, "th:1")] + // existing rv/th replaced, unrelated sub-key preserved in original order + [InlineData("foo:bar;rv:1;th:2", 0xef284ace7a91e1UL, 0xe6666666666668UL, "rv:ef284ace7a91e1;th:e6666666666668;foo:bar")] + // malformed existing rv/th still stripped even though we're not re-deriving them + [InlineData("rv:zzzz;th:2;foo:bar", 0xef284ace7a91e1UL, null, "rv:ef284ace7a91e1;foo:bar")] + // rv/th both null, unrelated sub-key preserved + [InlineData("foo:bar", null, null, "foo:bar")] + public void SetRvTh_RewritesRvAndThInPlace(string? raw, ulong? rv, ulong? th, string? expected) + { + OtelTraceStateHelpers.SetRvTh(raw, rv, th).Should().Be(expected); + } + + [Fact] + public void SetRvTh_ThZero_EmitsSingleZeroDigit() + { + OtelTraceStateHelpers.SetRvTh(null, rv: null, th: 0UL).Should().Be("th:0"); + } + } +} diff --git a/tracer/test/Datadog.Trace.Tests/Propagators/W3CTraceContextPropagatorTests.cs b/tracer/test/Datadog.Trace.Tests/Propagators/W3CTraceContextPropagatorTests.cs index 3e668b8a8146..d3c4dd49f2d4 100644 --- a/tracer/test/Datadog.Trace.Tests/Propagators/W3CTraceContextPropagatorTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Propagators/W3CTraceContextPropagatorTests.cs @@ -9,6 +9,7 @@ using Datadog.Trace.ExtensionMethods; using Datadog.Trace.Headers; using Datadog.Trace.Propagators; +using Datadog.Trace.Sampling; using Datadog.Trace.Tagging; using Datadog.Trace.Tests.Util; using FluentAssertions; @@ -231,6 +232,32 @@ public void CreateTraceStateHeader_With128Bit_TraceId() tracestate.Should().Be("dd=s:2;p:0000000000000002"); } + [Fact] + public void CreateTraceStateHeader_EmitsOtRightAfterDd_WhenOtelTraceStateIsSet() + { + var traceContext = new TraceContext(new StubDatadogTracer()); + var spanContext = new SpanContext(parent: SpanContext.None, traceContext, serviceName: null, traceId: (TraceId)1, spanId: 2) + { + OtelTraceState = "rv:ef284ace7a91e1;th:e6666666666668", + AdditionalW3CTraceState = "congo=t61rcWkgMzE" + }; + + var tracestate = W3CTraceContextPropagator.CreateTraceStateHeader(spanContext); + + tracestate.Should().Be("dd=s:1;p:0000000000000002,ot=rv:ef284ace7a91e1;th:e6666666666668,congo=t61rcWkgMzE"); + } + + [Fact] + public void CreateTraceStateHeader_OmitsOtMember_WhenOtelTraceStateIsNull() + { + var traceContext = new TraceContext(new StubDatadogTracer()); + var spanContext = new SpanContext(parent: SpanContext.None, traceContext, serviceName: null, traceId: (TraceId)1, spanId: 2); + + var tracestate = W3CTraceContextPropagator.CreateTraceStateHeader(spanContext); + + tracestate.Should().NotContain("ot="); + } + [Fact] public void Inject_IHeadersCollection() { @@ -404,6 +431,29 @@ public void ParseTraceStateWithLastParent() traceState.Should().BeEquivalentTo(expected); } + [Theory] + [InlineData("dd=s:1;o:rum", null)] + [InlineData("dd=s:1,ot=rv:ef284ace7a91e1;th:e6666666666668", "rv:ef284ace7a91e1;th:e6666666666668")] + [InlineData("ot=th:e6666666666668,dd=s:1", "th:e6666666666668")] + [InlineData("foo=bar,dd=s:1,ot=rv:1,baz=qux", "rv:1")] + [InlineData("dd=s:1,ot=", "")] + public void ParseTraceState_CapturesRawOtelTraceState(string header, string expectedOtTraceState) + { + var traceState = W3CTraceContextPropagator.ParseTraceState(header); + traceState.OtTraceState.Should().Be(expectedOtTraceState); + } + + [Theory] + [InlineData("dd=s:1;o:rum,foo=bar", null, "foo=bar")] + [InlineData("dd=s:1,ot=rv:1;th:2,foo=bar", "rv:1;th:2", "foo=bar")] + [InlineData("foo=bar,ot=rv:1,dd=s:1,baz=qux", "rv:1", "foo=bar,baz=qux")] + public void SplitTraceStateValues_ExtractsOtValues(string header, string expectedOt, string expectedAdditional) + { + W3CTraceContextPropagator.SplitTraceStateValues(header, out _, out var otValues, out var additionalValues); + otValues.Should().Be(expectedOt); + additionalValues.Should().Be(expectedAdditional); + } + [Fact] public void MissingLastParentId_ShouldBe_Zeroes() { @@ -800,6 +850,128 @@ public void Extract_MatchingSampled1_UsesTracestateSamplingPriority(int sampling opts => opts.ExcludingMissingMembers()); } + [Fact] + public void Extract_CopiesOtelTraceStateOntoSpanContext() + { + var headers = new Mock(MockBehavior.Strict); + + headers.Setup(h => h.GetValues("traceparent")) + .Returns(new[] { "00-000000000000000000000000075bcd15-000000003ade68b1-01" }); + + headers.Setup(h => h.GetValues("tracestate")) + .Returns(new[] { "dd=s:1,ot=rv:ef284ace7a91e1;th:e6666666666668" }); + + var result = W3CPropagator.Extract(headers.Object); + + headers.Verify(h => h.GetValues("traceparent"), Times.Once()); + headers.Verify(h => h.GetValues("tracestate"), Times.Once()); + headers.VerifyNoOtherCalls(); + + result.SpanContext!.OtelTraceState.Should().Be("rv:ef284ace7a91e1;th:e6666666666668"); + } + + [Theory] + [InlineData("rv:ef284ace7a91e1;th:e6666666666668")] // A2: full rv;th forwarded unchanged + [InlineData("th:e6666666666668")] // A2b: th-only forwards th, fabricates no rv + public void Continuation_ForwardsOtelTraceStateUnchanged_RegardlessOfLocalRateConfig(string inboundOtelTraceState) + { + var headers = new Mock(MockBehavior.Strict); + + headers.Setup(h => h.GetValues("traceparent")) + .Returns(new[] { "00-00000000000000000000000000000001-0000000000000001-01" }); + + headers.Setup(h => h.GetValues("tracestate")) + .Returns(new[] { $"dd=s:1,ot={inboundOtelTraceState}" }); + + var result = W3CPropagator.Extract(headers.Object); + + result.SpanContext!.OtelTraceState.Should().Be(inboundOtelTraceState); + + var tracestate = W3CTraceContextPropagator.CreateTraceStateHeader(result.SpanContext); + tracestate.Should().Contain($"ot={inboundOtelTraceState}"); + } + + [Fact] + public void Continuation_SampledWithoutOtMember_FabricatesNoOtelTraceState() + { + var headers = new Mock(MockBehavior.Strict); + + headers.Setup(h => h.GetValues("traceparent")) + .Returns(new[] { "00-00000000000000000000000000000001-0000000000000001-01" }); + + headers.Setup(h => h.GetValues("tracestate")) + .Returns(new[] { "dd=s:1" }); + + var result = W3CPropagator.Extract(headers.Object); + + result.SpanContext!.OtelTraceState.Should().BeNull(); + + var tracestate = W3CTraceContextPropagator.CreateTraceStateHeader(result.SpanContext); + tracestate.Should().NotContain("ot="); + } + + [Theory] + [InlineData("th:zz;rv:ef284ace7a91e1")] // malformed th, well-formed rv + [InlineData("th:e6666666666668;rv:zz")] // well-formed th, malformed rv + [InlineData("th:zz;rv:zz")] // both malformed + [InlineData("unknownkey:whatever")] // unrecognized sub-key entirely + public void Continuation_MalformedOrUnknownOtContent_RoundTripsByteForByte(string malformedOtelTraceState) + { + var headers = new Mock(MockBehavior.Strict); + + headers.Setup(h => h.GetValues("traceparent")) + .Returns(new[] { "00-00000000000000000000000000000001-0000000000000001-01" }); + + headers.Setup(h => h.GetValues("tracestate")) + .Returns(new[] { $"dd=s:1,ot={malformedOtelTraceState}" }); + + var result = W3CPropagator.Extract(headers.Object); + + var tracestate = W3CTraceContextPropagator.CreateTraceStateHeader(result.SpanContext!); + tracestate.Should().Contain($"ot={malformedOtelTraceState}"); + } + + [Fact] + public void Continuation_MultiVendorTracestate_RoundTripsFully_WithDdThenOtOrdering() + { + var headers = new Mock(MockBehavior.Strict); + + headers.Setup(h => h.GetValues("traceparent")) + .Returns(new[] { "00-00000000000000000000000000000001-0000000000000001-01" }); + + headers.Setup(h => h.GetValues("tracestate")) + .Returns(new[] { "foo1=bar1,dd=s:1,ot=rv:ef284ace7a91e1;th:e6666666666668;unknownsubkey:x,congo=t61rcWkgMzE" }); + + var result = W3CPropagator.Extract(headers.Object); + + result.SpanContext!.OtelTraceState.Should().Be("rv:ef284ace7a91e1;th:e6666666666668;unknownsubkey:x"); + result.SpanContext!.AdditionalW3CTraceState.Should().Be("foo1=bar1,congo=t61rcWkgMzE"); + + var tracestate = W3CTraceContextPropagator.CreateTraceStateHeader(result.SpanContext); + var ddIndex = tracestate.IndexOf("dd=", StringComparison.Ordinal); + var otIndex = tracestate.IndexOf("ot=", StringComparison.Ordinal); + ddIndex.Should().BeLessThan(otIndex); + } + + [Fact] + public void RootTrace_ProbabilityKeepAtKnownRate_EmitsRfcWorkedExampleOtelTraceState() + { + // Simulates a brand-new root trace (no incoming ot=) sampled at rate=0.1 + // with trace_id_low64 = 0xfff972474538efff, matching the RFC's worked example. + var traceContext = TraceContextTestHelpers.CreateTraceContextWithRootSpan(traceIdLower: 0xfff972474538efff); + + traceContext.SetSamplingPriority( + priority: SamplingPriorityValues.UserKeep, + mechanism: SamplingMechanism.LocalTraceSamplingRule, + rate: 0.1f, + sample: true); + + var spanContext = traceContext.RootSpan!.Context; + var tracestate = W3CTraceContextPropagator.CreateTraceStateHeader(spanContext); + + tracestate.Should().Contain("ot=rv:ef284ace7a91e1;th:e6666666666668"); + } + [Theory] [InlineData(SamplingPriorityValues.AutoReject)] [InlineData(SamplingPriorityValues.UserReject)] diff --git a/tracer/test/Datadog.Trace.Tests/Sampling/TraceSamplerTests.cs b/tracer/test/Datadog.Trace.Tests/Sampling/TraceSamplerTests.cs index f79d28eada8a..ffc6ea30f7b1 100644 --- a/tracer/test/Datadog.Trace.Tests/Sampling/TraceSamplerTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Sampling/TraceSamplerTests.cs @@ -173,6 +173,25 @@ public async Task Choose_Between_Sampling_Mechanisms() mechanism2.Should().Be(SamplingMechanism.AgentRate); } + [Fact] + public async Task MakeSamplingDecision_ReturnsSampleField_MatchingKnuthOutcome() + { + var settings = TracerSettings.Create(new() { { ConfigurationKeys.ServiceName, ServiceName } }); + await using var tracer = TracerHelper.CreateWithFakeAgent(settings); + + using var scope = (Scope)tracer.StartActive(OperationName); + scope.Span.Context.TraceContext.Environment = Env; + + var builder = new TraceSampler.Builder(new NoLimits()); + builder.RegisterAgentSamplingRule(new AgentSamplingRule()); + var sampler = builder.Build(); + sampler.SetDefaultSampleRates(new Dictionary { { $"service:{ServiceName},env:{Env}", 1f } }); + + var decision = sampler.MakeSamplingDecision(scope.Span); + + decision.Sample.Should().BeTrue(); + } + private async Task RunSamplerTest( ITraceSampler sampler, int iterations, diff --git a/tracer/test/Datadog.Trace.Tests/Tagging/TagsListTests.cs b/tracer/test/Datadog.Trace.Tests/Tagging/TagsListTests.cs index 614693ddcd6f..f8873c436df5 100644 --- a/tracer/test/Datadog.Trace.Tests/Tagging/TagsListTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Tagging/TagsListTests.cs @@ -174,7 +174,7 @@ public async Task Serialization_RootSpan() deserializedSpan.Tags.Should().Contain(Tags.Propagated.DecisionMaker, SamplingMechanism.Default); deserializedSpan.Tags.Should().Contain(Tags.Propagated.TraceIdUpper, hexStringTraceId); deserializedSpan.Tags.Should().ContainKey(Tags.ProcessTags); - deserializedSpan.Tags.Should().HaveCount(customTagCount + 6); + deserializedSpan.Tags.Should().HaveCount(customTagCount + 7); deserializedSpan.Metrics.Should().Contain(Metrics.SamplingPriority, 1); deserializedSpan.Metrics.Should().Contain(Metrics.SamplingLimitDecision, 0.75); @@ -219,7 +219,7 @@ public async Task Serialization_ServiceEntrySpan() deserializedSpan.Tags.Should().ContainKey(Tags.BaseService); deserializedSpan.Tags[Tags.BaseService].Should().Be(_tracer.DefaultServiceName); deserializedSpan.Tags.Should().ContainKey(Tags.ProcessTags); - deserializedSpan.Tags.Should().HaveCount(customTagCount + 7); + deserializedSpan.Tags.Should().HaveCount(customTagCount + 8); deserializedSpan.Metrics.Should().Contain(Metrics.SamplingLimitDecision, 0.75); deserializedSpan.Metrics.Should().Contain(Metrics.TopLevelSpan, 1); @@ -260,7 +260,7 @@ public async Task Serialization_ChildSpan() deserializedSpan.Tags.Should().ContainKey(Tags.BaseService); deserializedSpan.Tags[Tags.BaseService].Should().Be(_tracer.DefaultServiceName); deserializedSpan.Tags.Should().ContainKey(Tags.ProcessTags); - deserializedSpan.Tags.Should().HaveCount(customTagCount + 6); + deserializedSpan.Tags.Should().HaveCount(customTagCount + 7); deserializedSpan.Metrics.Should().Contain(Metrics.SamplingLimitDecision, 0.75); deserializedSpan.Metrics.Should().HaveCount(customTagCount + 1); diff --git a/tracer/test/Datadog.Trace.Tests/TraceContextTests.cs b/tracer/test/Datadog.Trace.Tests/TraceContextTests.cs index 961395c2b130..39e80bddd75b 100644 --- a/tracer/test/Datadog.Trace.Tests/TraceContextTests.cs +++ b/tracer/test/Datadog.Trace.Tests/TraceContextTests.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Datadog.Trace.Agent; using Datadog.Trace.Configuration; +using Datadog.Trace.Propagators; using Datadog.Trace.Sampling; using Datadog.Trace.TestHelpers; using Datadog.Trace.TestHelpers.TestTracer; @@ -217,5 +218,188 @@ public async Task Null_Service_Names_Dont_Throw() span.SetService(null, null); span.Finish(); // should not throw } + + [Fact] + public void SetSamplingPriority_RootProbabilityKeep_DerivesRvTh_MatchesRfcWorkedExample() + { + // RFC worked example: trace_id_low64 = 0xfff972474538efff, rate = 0.1 + // -> ot=rv:ef284ace7a91e1;th:e6666666666668 + var traceContext = TraceContextTestHelpers.CreateTraceContextWithRootSpan(traceIdLower: 0xfff972474538efff); + + traceContext.SetSamplingPriority( + priority: SamplingPriorityValues.UserKeep, + mechanism: SamplingMechanism.LocalTraceSamplingRule, + rate: 0.1f, + sample: true); + + traceContext.OtelTraceState.Should().Be("rv:ef284ace7a91e1;th:e6666666666668"); + } + + [Fact] + public void SetSamplingPriority_RootProbabilityDrop_StillEmitsTh() + { + var traceContext = TraceContextTestHelpers.CreateTraceContextWithRootSpan(traceIdLower: 0xfff972474538efff); + + traceContext.SetSamplingPriority( + priority: SamplingPriorityValues.UserReject, + mechanism: SamplingMechanism.LocalTraceSamplingRule, + rate: 0.1f, + sample: false); + + traceContext.OtelTraceState.Should().Contain("th:e6666666666668"); + } + + [Fact] + public void SetSamplingPriority_ImprecisionClamp_ForcesAgreementWithDdDecision() + { + // RFC §3 example: trace_id_low64 = 0x03a93ee8b1999f00, rate = 0.1 disagrees pre-clamp + var traceIdLower = 0x03a93ee8b1999f00UL; + var sample = SamplingHelpers.SampleByRate(traceIdLower, 0.1); + var traceContext = TraceContextTestHelpers.CreateTraceContextWithRootSpan(traceIdLower); + + traceContext.SetSamplingPriority( + priority: sample ? SamplingPriorityValues.UserKeep : SamplingPriorityValues.UserReject, + mechanism: SamplingMechanism.LocalTraceSamplingRule, + rate: 0.1f, + sample: sample); + + var rv = OtelTraceStateHelpers.ExtractRv(traceContext.OtelTraceState)!.Value; + var th = ParseThForTest(traceContext.OtelTraceState); + (rv >= th).Should().Be(sample); // post-clamp, rv>=th must agree with DD's actual keep/drop decision + } + + [Fact] + public void SetSamplingPriority_NonProbabilityMechanism_DoesNotDeriveOtelTraceState() + { + var traceContext = TraceContextTestHelpers.CreateTraceContextWithRootSpan(traceIdLower: 1); + + traceContext.SetSamplingPriority(SamplingPriorityValues.UserKeep, SamplingMechanism.Manual); + + traceContext.OtelTraceState.Should().BeNull(); + } + + [Fact] + public void SetSamplingPriority_RateLimiterDemotesKeep_StripsThButKeepsRv() + { + var traceContext = TraceContextTestHelpers.CreateTraceContextWithRootSpan(traceIdLower: 0xfff972474538efff); + + // sample=true (probability said keep) but final priority is UserReject (limiter demoted it) + traceContext.SetSamplingPriority( + priority: SamplingPriorityValues.UserReject, + mechanism: SamplingMechanism.LocalTraceSamplingRule, + rate: 0.1f, + limiterRate: 0.05f, + sample: true); + + traceContext.OtelTraceState.Should().Be("rv:ef284ace7a91e1"); + } + + [Fact] + public void TraceSampler_LimiterDemotesKeep_ErasesThOnTraceContext_ViaGetOrMakeSamplingDecision() + { + var builder = new TraceSampler.Builder(new TracerRateLimiter(maxTracesPerInterval: 0, intervalMilliseconds: null)); + builder.RegisterRule(new GlobalSamplingRateRule(1.0f)); + var sampler = builder.Build(); + + var tracer = new StubDatadogTracer(sampler); + var rootSpan = new Span(new SpanContext(0xfff972474538efffUL, RandomIdGenerator.Shared.NextSpanId()), DateTimeOffset.UtcNow); + var traceContext = new TraceContext(tracer); + traceContext.AddSpan(rootSpan); + + traceContext.GetOrMakeSamplingDecision(); + + traceContext.OtelTraceState.Should().Be("rv:ef284ace7a91e1"); + } + + [Fact] + public void SetSamplingPriority_RateZero_ThStaysWithin56BitDomain() + { + var traceContext = TraceContextTestHelpers.CreateTraceContextWithRootSpan(traceIdLower: 0xfff972474538efff); + + traceContext.SetSamplingPriority( + priority: SamplingPriorityValues.UserReject, + mechanism: SamplingMechanism.LocalTraceSamplingRule, + rate: 0.0f, + sample: false); + + var otelState = traceContext.OtelTraceState; + otelState.Should().NotBeNull(); + var th = ParseThForTest(otelState!); + th.Should().BeLessOrEqualTo((1UL << 56) - 1); + } + + [Fact] + public void SetSamplingPriority_RateOne_InconsistentDropDoesNotUnderflow() + { + var traceContext = TraceContextTestHelpers.CreateTraceContextWithRootSpan(traceIdLower: 0xfff972474538efff); + + traceContext.SetSamplingPriority( + priority: SamplingPriorityValues.UserReject, + mechanism: SamplingMechanism.LocalTraceSamplingRule, + rate: 1.0f, + sample: false); + + var rv = OtelTraceStateHelpers.ExtractRv(traceContext.OtelTraceState)!.Value; + rv.Should().Be(0UL); + } + + [Fact] + public void SetSamplingPriority_NaNRate_DoesNotThrow() + { + var traceContext = TraceContextTestHelpers.CreateTraceContextWithRootSpan(traceIdLower: 1); + + Action act = () => traceContext.SetSamplingPriority( + priority: SamplingPriorityValues.UserKeep, + mechanism: SamplingMechanism.Default, + rate: float.NaN, + sample: true); + + act.Should().NotThrow(); + } + + [Fact] + public void SetSamplingPriority_ManualOverride_StripsInheritedThButKeepsRv() + { + var traceContext = TraceContextTestHelpers.CreateTraceContextWithRootSpan(traceIdLower: 1); + traceContext.OtelTraceState = "rv:ef284ace7a91e1;th:e6666666666668"; + + traceContext.SetSamplingPriority(SamplingPriorityValues.UserKeep, SamplingMechanism.Manual); + + traceContext.OtelTraceState.Should().Be("rv:ef284ace7a91e1"); + } + + [Fact] + public void SetSamplingPriority_AsmOverride_StripsInheritedThButKeepsRv() + { + var traceContext = TraceContextTestHelpers.CreateTraceContextWithRootSpan(traceIdLower: 1); + traceContext.OtelTraceState = "th:e6666666666668"; + + traceContext.SetSamplingPriority(SamplingPriorityValues.UserReject, SamplingMechanism.Asm); + + traceContext.OtelTraceState.Should().BeNull(); + } + + [Fact] + public void SetSamplingPriority_ManualOverride_NoInheritedState_StaysNull() + { + var traceContext = TraceContextTestHelpers.CreateTraceContextWithRootSpan(traceIdLower: 1); + + traceContext.SetSamplingPriority(SamplingPriorityValues.UserKeep, SamplingMechanism.Manual); + + traceContext.OtelTraceState.Should().BeNull(); + } + + private static ulong ParseThForTest(string otelTraceState) + { + foreach (var item in otelTraceState.Split(';')) + { + if (item.StartsWith("th:", StringComparison.Ordinal)) + { + return Convert.ToUInt64(item.Substring(3), 16); + } + } + + throw new InvalidOperationException("no th found"); + } } } diff --git a/tracer/test/Datadog.Trace.Tests/TraceContextTests_KnuthSamplingRate.cs b/tracer/test/Datadog.Trace.Tests/TraceContextTests_KnuthSamplingRate.cs index 9d35ef134b5a..c12fdb329e8e 100644 --- a/tracer/test/Datadog.Trace.Tests/TraceContextTests_KnuthSamplingRate.cs +++ b/tracer/test/Datadog.Trace.Tests/TraceContextTests_KnuthSamplingRate.cs @@ -20,6 +20,7 @@ public class TraceContextTests_KnuthSamplingRate [InlineData(SamplingPriorityValues.UserKeep, SamplingMechanism.LocalTraceSamplingRule, 0.25f, "0.25")] [InlineData(SamplingPriorityValues.UserKeep, SamplingMechanism.RemoteUserSamplingRule, 0.75f, "0.75")] [InlineData(SamplingPriorityValues.UserKeep, SamplingMechanism.RemoteAdaptiveSamplingRule, 0.333333f, "0.333333")] + [InlineData(SamplingPriorityValues.AutoKeep, SamplingMechanism.Default, 0.5f, "0.5")] public void SetSamplingPriority_SetsKsrTag_ForApplicableMechanisms( int samplingPriority, string samplingMechanism, float rate, string expectedKsr) { @@ -52,7 +53,6 @@ public void SetSamplingPriority_SetsKsrTag_EvenForDropDecisions( [Theory] [InlineData(SamplingPriorityValues.UserKeep, SamplingMechanism.Manual, 0.5f)] [InlineData(SamplingPriorityValues.UserKeep, SamplingMechanism.Asm, 0.5f)] - [InlineData(SamplingPriorityValues.AutoKeep, SamplingMechanism.Default, 0.5f)] public void SetSamplingPriority_DoesNotSetKsrTag_ForNonApplicableMechanisms( int samplingPriority, string samplingMechanism, float rate) { diff --git a/tracer/test/Datadog.Trace.Tests/Util/StubDatadogTracer.cs b/tracer/test/Datadog.Trace.Tests/Util/StubDatadogTracer.cs index ee8ab7ba170a..ec1e6ddf5864 100644 --- a/tracer/test/Datadog.Trace.Tests/Util/StubDatadogTracer.cs +++ b/tracer/test/Datadog.Trace.Tests/Util/StubDatadogTracer.cs @@ -3,12 +3,15 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. // +#nullable enable + using System; using System.Collections.Generic; using Datadog.Trace.Agent; using Datadog.Trace.Configuration; using Datadog.Trace.Configuration.Schema; using Datadog.Trace.Configuration.Telemetry; +using Datadog.Trace.Sampling; namespace Datadog.Trace.Tests.Util; @@ -20,11 +23,21 @@ public StubDatadogTracer() } public StubDatadogTracer(TracerSettings settings) + : this(settings, traceSampler: null) + { + } + + public StubDatadogTracer(ITraceSampler traceSampler) + : this(new TracerSettings(NullConfigurationSource.Instance), traceSampler) + { + } + + public StubDatadogTracer(TracerSettings settings, ITraceSampler? traceSampler) { DefaultServiceName = "stub-service"; Settings = settings; var namingSchema = new NamingSchema(SchemaVersion.V0, false, false, DefaultServiceName, null, null); - PerTraceSettings = new PerTraceSettings(null, null, namingSchema, MutableSettings.CreateWithoutDefaultSources(Settings, new ConfigurationTelemetry())); + PerTraceSettings = new PerTraceSettings(traceSampler, null, namingSchema, MutableSettings.CreateWithoutDefaultSources(Settings, new ConfigurationTelemetry())); } public string DefaultServiceName { get; } diff --git a/tracer/test/Datadog.Trace.Tests/Util/TraceContextTestHelpers.cs b/tracer/test/Datadog.Trace.Tests/Util/TraceContextTestHelpers.cs new file mode 100644 index 000000000000..554c85cb56cf --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/Util/TraceContextTestHelpers.cs @@ -0,0 +1,21 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +using System; +using Datadog.Trace.Util; + +namespace Datadog.Trace.Tests.Util; + +internal static class TraceContextTestHelpers +{ + public static TraceContext CreateTraceContextWithRootSpan(ulong traceIdLower) + { + var traceContext = new TraceContext(new StubDatadogTracer()); + var spanContext = new SpanContext(parent: SpanContext.None, traceContext, serviceName: null, traceId: (TraceId)traceIdLower, spanId: RandomIdGenerator.Shared.NextSpanId()); + var rootSpan = new Span(spanContext, DateTimeOffset.UtcNow); + traceContext.AddSpan(rootSpan); + return traceContext; + } +}