diff --git a/tracer/src/Datadog.Trace/Agent/DiscoveryService/AgentTraceFilterConfig.cs b/tracer/src/Datadog.Trace/Agent/DiscoveryService/AgentTraceFilterConfig.cs index 7730dca828e9..2436203f368a 100644 --- a/tracer/src/Datadog.Trace/Agent/DiscoveryService/AgentTraceFilterConfig.cs +++ b/tracer/src/Datadog.Trace/Agent/DiscoveryService/AgentTraceFilterConfig.cs @@ -18,7 +18,7 @@ internal sealed record AgentTraceFilterConfig( List? FilterTagsReject, List? FilterTagsRegexRequire, List? FilterTagsRegexReject, - List? IgnoreResources) + List? IgnoreResourcesRegex) { public static readonly AgentTraceFilterConfig Empty = new(null, null, null, null, null); @@ -27,5 +27,5 @@ internal sealed record AgentTraceFilterConfig( FilterTagsReject is { Count: > 0 } || FilterTagsRegexRequire is { Count: > 0 } || FilterTagsRegexReject is { Count: > 0 } || - IgnoreResources is { Count: > 0 }; + IgnoreResourcesRegex is { Count: > 0 }; } diff --git a/tracer/src/Datadog.Trace/Agent/DiscoveryService/DiscoveryService.cs b/tracer/src/Datadog.Trace/Agent/DiscoveryService/DiscoveryService.cs index dda540566d3d..54b5844e35c4 100644 --- a/tracer/src/Datadog.Trace/Agent/DiscoveryService/DiscoveryService.cs +++ b/tracer/src/Datadog.Trace/Agent/DiscoveryService/DiscoveryService.cs @@ -348,7 +348,7 @@ private async Task ProcessDiscoveryResponse(IApiResponse response) var clientDropP0 = jObject["client_drop_p0s"]?.Value() ?? false; var spanMetaStructs = jObject["span_meta_structs"]?.Value() ?? false; var spanEvents = jObject["span_events"]?.Value() ?? false; - var peerTags = (jObject["peer_tags"] as JArray)?.Values().Where(x => !string.IsNullOrEmpty(x)).Distinct().OrderBy(x => x).ToList(); + var peerTags = (jObject["peer_tags"] as JArray)?.Values().ToList(); var obfuscationVersion = jObject["obfuscation_version"]?.Value() ?? 0; // Parse trace filter configuration diff --git a/tracer/src/Datadog.Trace/Agent/IStatsAggregator.cs b/tracer/src/Datadog.Trace/Agent/IStatsAggregator.cs index 5837ff767d3b..c8600100732a 100644 --- a/tracer/src/Datadog.Trace/Agent/IStatsAggregator.cs +++ b/tracer/src/Datadog.Trace/Agent/IStatsAggregator.cs @@ -51,6 +51,6 @@ internal interface IStatsAggregator Task DisposeAsync(); - StatsAggregationKey BuildKey(Span span, out List utf8PeerTags); + StatsAggregationKey BuildKey(Span span); } } diff --git a/tracer/src/Datadog.Trace/Agent/NullStatsAggregator.cs b/tracer/src/Datadog.Trace/Agent/NullStatsAggregator.cs index b6a102b98cfd..d76406d00ac7 100644 --- a/tracer/src/Datadog.Trace/Agent/NullStatsAggregator.cs +++ b/tracer/src/Datadog.Trace/Agent/NullStatsAggregator.cs @@ -28,10 +28,8 @@ public Task DisposeAsync() return Task.CompletedTask; } - public StatsAggregationKey BuildKey(Span span, out List utf8PeerTags) + public StatsAggregationKey BuildKey(Span span) { - utf8PeerTags = []; - var rawHttpStatusCode = span.GetTag(Tags.HttpStatusCode); if (rawHttpStatusCode is null || !int.TryParse(rawHttpStatusCode, out var httpStatusCode)) { diff --git a/tracer/src/Datadog.Trace/Agent/StatsAggregator.cs b/tracer/src/Datadog.Trace/Agent/StatsAggregator.cs index 449f069f25c8..5f19a6b18504 100644 --- a/tracer/src/Datadog.Trace/Agent/StatsAggregator.cs +++ b/tracer/src/Datadog.Trace/Agent/StatsAggregator.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -28,6 +29,7 @@ internal sealed class StatsAggregator : IStatsAggregator private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(); private static readonly List EmptyPeerTags = []; private static readonly byte[] PeerTagSeparator = [0]; + private static readonly byte[] BaseServiceUtf8Prefix = EncodingHelpers.Utf8NoBom.GetBytes(Tags.BaseService + ":"); private readonly StatsBuffer[] _buffers; @@ -54,7 +56,7 @@ internal sealed class StatsAggregator : IStatsAggregator private int _tracerObfuscationVersion; private TraceFilter _traceFilter; - private List _peerTagKeys = []; + private List _peerTagKeys = []; internal StatsAggregator(IApi api, TracerSettings settings, IDiscoveryService discoveryService, bool isOtlp) { @@ -248,10 +250,16 @@ internal SpanCollection ObfuscateTrace(in SpanCollection trace) return trace; } - public StatsAggregationKey BuildKey(Span span, out List utf8PeerTags) - => BuildKey(span, Volatile.Read(ref _peerTagKeys), out utf8PeerTags); + public StatsAggregationKey BuildKey(Span span) + => BuildKey(span, Volatile.Read(ref _peerTagKeys), out _); - internal StatsAggregationKey BuildKey(Span span, List peerTagKeys, out List utf8PeerTags) + /// + /// Computes a for the given span, including the peer tags hash. + /// The carries context to + /// so the cold path can skip re-deriving spanKind/baseService and pre-allocate the result list. + /// + [TestingAndPrivateOnly] + internal StatsAggregationKey BuildKey(Span span, List peerTagKeys, out PeerTagResults peerTagResults) { var rawHttpStatusCode = span.GetTag(Tags.HttpStatusCode); @@ -286,30 +294,24 @@ internal StatsAggregationKey BuildKey(Span span, List peerTagKeys, out L } // Based on https://github.com/DataDog/datadog-agent/blob/ce22e11ee71e55be717b9d9a3f8f3d7721a9c6d7/pkg/trace/stats/span_concentrator.go#L53-L99 - // Peer tags are extracted for client/producer/consumer spans + // Peer tags are extracted for client/server/producer/consumer spans. // If the span kind is missing or internal, and we have a "base service" tag `_dd.base_service` - // then we only aggregate based on the `_dd.base_service` - // TODO: work out how to optimize the peer tags allocations, to avoid all the extra utf8 allocations - // - on .NET Core these are only necessary for the first instance of the key, but this makes for a tricky - // chicken and egg - we need to convert everything to utf-8, so that we can get the hash, so that we - // know whether we need the tags as byte[] or not.. + // then we only aggregate based on the `_dd.base_service`. + // This computes only the hash; see GetEncodedPeerTags() for the cold-path encoding. ulong peerTagsHash; - if ((string.IsNullOrEmpty(spanKind) || spanKind is SpanKinds.Internal) && span.GetTag(Tags.BaseService) is { Length: >0 } baseService) + if ((string.IsNullOrEmpty(spanKind) || spanKind is SpanKinds.Internal) && span.GetTag(Tags.BaseService) is { Length: > 0 } baseService) { - utf8PeerTags = [EncodingHelpers.Utf8NoBom.GetBytes($"{Tags.BaseService}:{baseService}")]; - peerTagsHash = FnvHash64.GenerateHash(utf8PeerTags[0], FnvHash64.Version.V1A); + peerTagsHash = HashTag(BaseServiceUtf8Prefix, baseService, FnvHash64.Version.V1A); + peerTagResults = new PeerTagResults { BaseService = baseService }; } else if (spanKind is SpanKinds.Client or SpanKinds.Server or SpanKinds.Producer or SpanKinds.Consumer) { - // Hash should be generated as TAGNAME:TAGVALUE, and should be in sorted order (we sort ahead of time) - // peerTagKeys should already be in sorted order - // We serialize to the utf-8 bytes because we need to serialize them during sending anyway - // TODO: Verify we get the same results as the go code - utf8PeerTags = EmptyPeerTags; - peerTagsHash = 0; - foreach (var tagKey in peerTagKeys) + // Hash should be generated as TAGNAME:TAGVALUE, in sorted order (peerTagKeys is pre-sorted). + ulong? previousHash = null; + var peerTagCount = 0; + foreach (var peerTag in peerTagKeys) { - var tagValue = span.GetTag(tagKey); + var tagValue = span.GetTag(peerTag.Name); if (string.IsNullOrEmpty(tagValue)) { continue; @@ -317,27 +319,23 @@ internal StatsAggregationKey BuildKey(Span span, List peerTagKeys, out L tagValue = IpAddressObfuscationUtil.QuantizePeerIpAddresses(tagValue); - if (ReferenceEquals(utf8PeerTags, EmptyPeerTags)) + if (previousHash.HasValue) { - // We're not setting the capacity here, because there's - // a _lot_ of potential peer tags, and _most_ of them won't apply - utf8PeerTags = new(); - } - else - { - // add the separator - peerTagsHash = FnvHash64.GenerateHash(PeerTagSeparator, FnvHash64.Version.V1A, peerTagsHash); + // add the separator between tags + previousHash = FnvHash64.GenerateHash(PeerTagSeparator, FnvHash64.Version.V1A, previousHash.Value); } - var bytes = EncodingHelpers.Utf8NoBom.GetBytes($"{tagKey}:{tagValue}"); - peerTagsHash = FnvHash64.GenerateHash(bytes, FnvHash64.Version.V1A, peerTagsHash); - utf8PeerTags.Add(bytes); + previousHash = HashTag(peerTag.Utf8Prefix, tagValue, FnvHash64.Version.V1A, previousHash); + peerTagCount++; } + + peerTagResults = new PeerTagResults { PeerTagCount = peerTagCount }; + peerTagsHash = previousHash ?? 0; } else { peerTagsHash = 0; - utf8PeerTags = EmptyPeerTags; + peerTagResults = default; } // When submitting trace metrics over OTLP, we must create inidividual timeseries @@ -362,6 +360,80 @@ internal StatsAggregationKey BuildKey(Span span, List peerTagKeys, out L peerTagsHash); } + /// + /// Hashes "keyPrefix + tagValue" using FNV-64. + /// The is a pre-encoded UTF-8 byte array (e.g. "tagKey:") and is + /// hashed directly. Only the needs UTF-8 encoding at call time. + /// +#if NETCOREAPP + [System.Runtime.CompilerServices.SkipLocalsInit] +#endif + private static ulong HashTag(byte[] keyPrefix, string tagValue, FnvHash64.Version version, ulong? initialHash = null) + { + // Hash the pre-encoded key prefix (e.g. "peer.service:") directly — no encoding needed + var hash = initialHash is { } h + ? FnvHash64.GenerateHash(keyPrefix, version, h) + : FnvHash64.GenerateHash(keyPrefix, version); + + // Now encode and hash just the tag value + var maxByteCount = EncodingHelpers.Utf8NoBom.GetMaxByteCount(tagValue.Length); +#if NETCOREAPP + const int maxStackLimit = 256; + + if (maxByteCount <= maxStackLimit) + { + Span buffer = stackalloc byte[maxStackLimit]; + var written = EncodingHelpers.Utf8NoBom.GetBytes(tagValue, buffer); + return FnvHash64.GenerateHash(buffer.Slice(0, written), version, hash); + } +#endif + + var rented = ArrayPool.Shared.Rent(maxByteCount); + try + { + var written = EncodingHelpers.Utf8NoBom.GetBytes(tagValue, charIndex: 0, charCount: tagValue.Length, rented, byteIndex: 0); + return FnvHash64.GenerateHash(rented, 0, written, version, hash); + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + /// + /// Encodes the peer tags for a span into a of UTF-8 byte arrays. + /// Called only on the cold path (new bucket creation). + /// Uses from + /// to skip re-deriving spanKind/baseService and to pre-allocate the result list. + /// + internal static List GetEncodedPeerTags(Span span, List peerTagKeys, in PeerTagResults results) + { + if (results.BaseService is not null) + { + return [EncodingHelpers.Utf8NoBom.GetBytes($"{Tags.BaseService}:{results.BaseService}")]; + } + + if (results.PeerTagCount == 0) + { + return EmptyPeerTags; + } + + var result = new List(results.PeerTagCount); + foreach (var peerTag in peerTagKeys) + { + var tagValue = span.GetTag(peerTag.Name); + if (string.IsNullOrEmpty(tagValue)) + { + continue; + } + + tagValue = IpAddressObfuscationUtil.QuantizePeerIpAddresses(tagValue); + result.Add(EncodingHelpers.Utf8NoBom.GetBytes($"{peerTag.Name}:{tagValue}")); + } + + return result; + } + internal async Task Flush() { // Use a do/while loop to still flush once if _processExit is already completed (this makes testing easier) @@ -440,13 +512,14 @@ private void AddToBuffer(Span span) return; } - var key = BuildKey(span, out var peerTags); - var buffer = CurrentBuffer; + var peerTagKeys = Volatile.Read(ref _peerTagKeys); + var key = BuildKey(span, peerTagKeys, out var peerTagResults); if (!buffer.Buckets.TryGetValue(key, out var bucket)) { - bucket = new StatsBucket(key, peerTags); + // Cold path: encode the peer tags for storage in the new bucket + bucket = new StatsBucket(key, GetEncodedPeerTags(span, peerTagKeys, in peerTagResults)); buffer.Buckets.Add(key, bucket); } @@ -490,9 +563,32 @@ private void HandleConfigUpdate(AgentConfiguration config) || parsedVersion.Major >= 8 || (parsedVersion.Major == 7 && parsedVersion.Minor >= 65)); - if (config.PeerTags is not null) + if (CanComputeStats.Value) + { + Log.Debug("Stats computation enabled."); + } + else + { + Log.Warning("Stats computation disabled because the detected agent does not support this feature."); + // early return, because there's no point doing all the extra work if stats isn't enabled anyway + return; + } + + if (config.PeerTags is { Count: > 0 }) + { + // Sort, deduplicate, and pre-compute the UTF-8 key prefixes so that + // BuildKey can hash without per-call string encoding. + var precomputed = new List(config.PeerTags.Count); + foreach (var tag in config.PeerTags.Where(x => !string.IsNullOrEmpty(x)).Distinct().OrderBy(x => x)) + { + precomputed.Add(new PeerTagKey(tag)); + } + + Interlocked.Exchange(ref _peerTagKeys, precomputed); + } + else { - Interlocked.Exchange(ref _peerTagKeys, config.PeerTags); + Interlocked.Exchange(ref _peerTagKeys, []); } // Update trace filter from agent configuration @@ -509,15 +605,19 @@ private void HandleConfigUpdate(AgentConfiguration config) const int tracerObfuscationVersion = 1; var agentVersion = config.ObfuscationVersion; Volatile.Write(ref _tracerObfuscationVersion, agentVersion > 0 && agentVersion <= tracerObfuscationVersion ? tracerObfuscationVersion : 0); + } - if (CanComputeStats.Value) - { - Log.Debug("Stats computation has been enabled."); - } - else - { - Log.Warning("Stats computation disabled because the detected agent does not support this feature."); - } + internal readonly struct PeerTagKey(string name) + { + public readonly string Name = name; + public readonly byte[] Utf8Prefix = EncodingHelpers.Utf8NoBom.GetBytes(name + ":"); + } + + internal readonly struct PeerTagResults + { + public int PeerTagCount { get; init; } + + public string BaseService { get; init; } } } } diff --git a/tracer/src/Datadog.Trace/Agent/TraceSamplers/RareSampler.cs b/tracer/src/Datadog.Trace/Agent/TraceSamplers/RareSampler.cs index 1d208d80f148..d71380b6a916 100644 --- a/tracer/src/Datadog.Trace/Agent/TraceSamplers/RareSampler.cs +++ b/tracer/src/Datadog.Trace/Agent/TraceSamplers/RareSampler.cs @@ -91,7 +91,7 @@ private bool SampleSpansAndUpdateSeenSpansIfKept(in SpanCollection trace) private bool SampleSpan(Span span) { - var key = _aggregator.BuildKey(span, out _); + var key = _aggregator.BuildKey(span); var isNewKey = _keys.Add(key); if (isNewKey) @@ -105,7 +105,7 @@ private bool SampleSpan(Span span) private void UpdateSpan(Span span) { - var key = _aggregator.BuildKey(span, out _); + var key = _aggregator.BuildKey(span); var isNewKey = _keys.Add(key); if (isNewKey) diff --git a/tracer/src/Datadog.Trace/Agent/TraceSamplers/TraceFilter.cs b/tracer/src/Datadog.Trace/Agent/TraceSamplers/TraceFilter.cs index 59337471f04e..a4ebb5d0ae96 100644 --- a/tracer/src/Datadog.Trace/Agent/TraceSamplers/TraceFilter.cs +++ b/tracer/src/Datadog.Trace/Agent/TraceSamplers/TraceFilter.cs @@ -26,7 +26,7 @@ internal sealed class TraceFilter private readonly List> _filterTagKeyValuesReject; private readonly List _filterTagsRegexRequire; private readonly List _filterTagsRegexReject; - private readonly List _ignoreResources; + private readonly List _ignoreResourcesRegex; private readonly bool _hasFilters; public TraceFilter(AgentTraceFilterConfig config) @@ -35,7 +35,7 @@ public TraceFilter(AgentTraceFilterConfig config) BuildFilterTags(config.FilterTagsReject, out _filterTagKeysReject, out _filterTagKeyValuesReject); _filterTagsRegexRequire = CompileTagFilters(config.FilterTagsRegexRequire); _filterTagsRegexReject = CompileTagFilters(config.FilterTagsRegexReject); - _ignoreResources = CompilePatterns(config.IgnoreResources); + _ignoreResourcesRegex = CompilePatterns(config.IgnoreResourcesRegex); // Short circuit because these are _relatively_ rare, so we can avoid all the work if needs be _hasFilters = _filterTagKeysRequire.Count > 0 @@ -44,7 +44,7 @@ public TraceFilter(AgentTraceFilterConfig config) || _filterTagKeyValuesReject.Count > 0 || _filterTagsRegexRequire.Count > 0 || _filterTagsRegexReject.Count > 0 - || _ignoreResources.Count > 0; + || _ignoreResourcesRegex.Count > 0; static void BuildFilterTags(List? filters, out List keyFilters, out List> keyValueFilters) { @@ -82,9 +82,9 @@ public bool ShouldKeepTrace(Span rootSpan) } // 1. Resource filtering: reject if resource matches any ignore_resources pattern - if (_ignoreResources.Count > 0 && !string.IsNullOrEmpty(rootSpan.ResourceName)) + if (_ignoreResourcesRegex.Count > 0 && !string.IsNullOrEmpty(rootSpan.ResourceName)) { - foreach (var pattern in _ignoreResources) + foreach (var pattern in _ignoreResourcesRegex) { if (pattern.IsMatch(rootSpan.ResourceName)) { diff --git a/tracer/test/Datadog.Trace.Tests/Agent/AgentWriterTests.cs b/tracer/test/Datadog.Trace.Tests/Agent/AgentWriterTests.cs index 21c4baccf2b6..88235d520b8d 100644 --- a/tracer/test/Datadog.Trace.Tests/Agent/AgentWriterTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Agent/AgentWriterTests.cs @@ -609,11 +609,7 @@ public TraceKeepState ProcessTrace(ref SpanCollection spans) public Task DisposeAsync() => Task.CompletedTask; - public StatsAggregationKey BuildKey(Span span, out List utf8PeerTags) - { - utf8PeerTags = []; - return new(); - } + public StatsAggregationKey BuildKey(Span span) => new(); } } } diff --git a/tracer/test/Datadog.Trace.Tests/Agent/StatsAggregatorTests.cs b/tracer/test/Datadog.Trace.Tests/Agent/StatsAggregatorTests.cs index ebc6b1e91e64..54453877fb77 100644 --- a/tracer/test/Datadog.Trace.Tests/Agent/StatsAggregatorTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Agent/StatsAggregatorTests.cs @@ -17,6 +17,7 @@ using Datadog.Trace.Sampling; using Datadog.Trace.TestHelpers.TestTracer; using Datadog.Trace.Tests.Util; +using Datadog.Trace.Util; using FluentAssertions; using Moq; using Xunit; @@ -223,7 +224,7 @@ public async Task CreatesDistinctBuckets_TS003() foreach (var span in spans) { - var key = aggregator.BuildKey(span, out _); + var key = aggregator.BuildKey(span); buffer.Buckets.Should().ContainKey(key); var bucket = buffer.Buckets[key]; @@ -357,7 +358,7 @@ public async Task CollectsTopLevelSpans_TS005() buffer.Buckets.Should().HaveCount(2); - var serviceKey = aggregator.BuildKey(simpleSpan, out _); + var serviceKey = aggregator.BuildKey(simpleSpan); buffer.Buckets.Should().ContainKey(serviceKey); var serviceBucket = buffer.Buckets[serviceKey]; @@ -371,7 +372,7 @@ public async Task CollectsTopLevelSpans_TS005() serviceBucket.OkSummary.GetSum().Should().BeApproximately( expectedOkDuration, expectedOkDuration * serviceBucket.OkSummary.IndexMapping.RelativeAccuracy); - var httpClientServiceKey = aggregator.BuildKey(httpClientServiceSpan, out _); + var httpClientServiceKey = aggregator.BuildKey(httpClientServiceSpan); buffer.Buckets.Should().ContainKey(httpClientServiceKey); var httpClientServiceBucket = buffer.Buckets[httpClientServiceKey]; @@ -568,7 +569,7 @@ public async Task ProcessTrace_WhenFilterRejects_ReturnsRejected() FilterTagsReject: ["env:production"], FilterTagsRegexRequire: null, FilterTagsRegexReject: null, - IgnoreResources: null); + IgnoreResourcesRegex: null); var discoveryService = new StubDiscoveryService(traceFilterConfig: filterConfig); await using var aggregator = new StatsAggregator(Mock.Of(), GetSettings(), discoveryService, isOtlp: false); @@ -594,7 +595,7 @@ public async Task ProcessTrace_WhenFilterKeepsAndSampled_ReturnsAggregateAndExpo FilterTagsReject: ["env:staging"], FilterTagsRegexRequire: null, FilterTagsRegexReject: null, - IgnoreResources: null); + IgnoreResourcesRegex: null); var discoveryService = new StubDiscoveryService(traceFilterConfig: filterConfig); await using var aggregator = new StatsAggregator(Mock.Of(), GetSettings(), discoveryService, isOtlp: false); @@ -621,7 +622,7 @@ public async Task ProcessTrace_WhenFilterKeepsAndNotSampled_ReturnsAggregateOnly FilterTagsReject: ["env:staging"], FilterTagsRegexRequire: null, FilterTagsRegexReject: null, - IgnoreResources: null); + IgnoreResourcesRegex: null); var discoveryService = new StubDiscoveryService(traceFilterConfig: filterConfig); await using var aggregator = new StatsAggregator(Mock.Of(), GetSettings(), discoveryService, isOtlp: false); @@ -662,7 +663,7 @@ public async Task ShouldFilterTrace_WhenFilterKeepsTrace_ReturnsFalse() FilterTagsReject: ["env:staging"], FilterTagsRegexRequire: null, FilterTagsRegexReject: null, - IgnoreResources: null); + IgnoreResourcesRegex: null); var discoveryService = new StubDiscoveryService(traceFilterConfig: filterConfig); await using var aggregator = new StatsAggregator(Mock.Of(), GetSettings(), discoveryService, isOtlp: false); @@ -687,7 +688,7 @@ public async Task ShouldFilterTrace_WhenFilterRejectsTrace_ReturnsTrue() FilterTagsReject: ["env:production"], FilterTagsRegexRequire: null, FilterTagsRegexReject: null, - IgnoreResources: null); + IgnoreResourcesRegex: null); var discoveryService = new StubDiscoveryService(traceFilterConfig: filterConfig); await using var aggregator = new StatsAggregator(Mock.Of(), GetSettings(), discoveryService, isOtlp: false); @@ -711,7 +712,7 @@ public async Task ShouldFilterTrace_WhenIgnoreResourceMatches_ReturnsTrue() FilterTagsReject: null, FilterTagsRegexRequire: null, FilterTagsRegexReject: null, - IgnoreResources: ["^GET /health"]); + IgnoreResourcesRegex: ["^GET /health"]); var discoveryService = new StubDiscoveryService(traceFilterConfig: filterConfig); await using var aggregator = new StatsAggregator(Mock.Of(), GetSettings(), discoveryService, isOtlp: false); @@ -857,11 +858,11 @@ public async Task SpanKindEligibility_ServerAndClientSpansAreIncluded() // parent, server child, client child are included; internal and no-kind are not buffer.Buckets.Should().HaveCount(3); - buffer.Buckets.Should().ContainKey(aggregator.BuildKey(parentSpan, out _)); - buffer.Buckets.Should().ContainKey(aggregator.BuildKey(serverChildSpan, out _)); - buffer.Buckets.Should().ContainKey(aggregator.BuildKey(clientChildSpan, out _)); - buffer.Buckets.Should().NotContainKey(aggregator.BuildKey(internalChildSpan, out _)); - buffer.Buckets.Should().NotContainKey(aggregator.BuildKey(noKindChildSpan, out _)); + buffer.Buckets.Should().ContainKey(aggregator.BuildKey(parentSpan)); + buffer.Buckets.Should().ContainKey(aggregator.BuildKey(serverChildSpan)); + buffer.Buckets.Should().ContainKey(aggregator.BuildKey(clientChildSpan)); + buffer.Buckets.Should().NotContainKey(aggregator.BuildKey(internalChildSpan)); + buffer.Buckets.Should().NotContainKey(aggregator.BuildKey(noKindChildSpan)); } finally { @@ -924,8 +925,8 @@ public async Task IsTraceRootCreatesDistinctBuckets() // They have the same resource/operation/type but different IsTraceRoot → 2 buckets buffer.Buckets.Should().HaveCount(2); - var rootKey = aggregator.BuildKey(rootSpan, out _); - var entryKey = aggregator.BuildKey(entrySpan, out _); + var rootKey = aggregator.BuildKey(rootSpan); + var entryKey = aggregator.BuildKey(entrySpan); rootKey.IsTraceRoot.Should().BeTrue(); entryKey.IsTraceRoot.Should().BeFalse(); } @@ -1133,8 +1134,8 @@ public async Task SamplingWeightIsApplied() aggregator.Add(sampledSpan, unweightedSpan); var buffer = aggregator.CurrentBuffer; - var sampledKey = aggregator.BuildKey(sampledSpan, out _); - var unweightedKey = aggregator.BuildKey(unweightedSpan, out _); + var sampledKey = aggregator.BuildKey(sampledSpan); + var unweightedKey = aggregator.BuildKey(unweightedSpan); buffer.Buckets[sampledKey].Hits.Should().BeApproximately(10.0, 0.001); buffer.Buckets[unweightedKey].Hits.Should().BeApproximately(1.0, 0.001); @@ -1179,6 +1180,155 @@ public async Task PeerTagsCreateDistinctBuckets() } } + [Theory] + [InlineData(SpanKinds.Client)] + [InlineData(SpanKinds.Producer)] + public async Task PeerTagsHash_MatchesGoAgent_SingleTag(string spanKind) + { + // Golden value from Go agent's TestNewAggregation in aggregation_test.go: + // peer.service:remote-service → hash 3430395298086625290 + // https://github.com/DataDog/datadog-agent/blob/4c45a7cf23b97bf6b904565f88d16e73da83842a/pkg/trace/stats/aggregation_test.go + var start = DateTimeOffset.UtcNow; + await using var aggregator = new StatsAggregator(Mock.Of(), GetSettings(), Mock.Of(), isOtlp: false); + + var span = CreateTopLevelSpan(start, "svc"); + span.SetTag(Tags.SpanKind, spanKind); + span.Tags.SetTag("peer.service", "remote-service"); + + List peerTagKeys = [new("peer.service")]; + var key = aggregator.BuildKey(span, peerTagKeys, out _); + + key.PeerTagsHash.Should().Be(3430395298086625290UL); + } + + [Fact] + public async Task PeerTagsHash_MatchesGoAgent_MultipleTags() + { + // Golden value from Go agent's TestNewAggregation in aggregation_test.go: + // db.instance:i-1234, db.system:postgres, peer.service:remote-service → hash 9894752672193411515 + // https://github.com/DataDog/datadog-agent/blob/4c45a7cf23b97bf6b904565f88d16e73da83842a/pkg/trace/stats/aggregation_test.go + var start = DateTimeOffset.UtcNow; + await using var aggregator = new StatsAggregator(Mock.Of(), GetSettings(), Mock.Of(), isOtlp: false); + + var span = CreateTopLevelSpan(start, "svc"); + span.SetTag(Tags.SpanKind, SpanKinds.Client); + span.Tags.SetTag("peer.service", "remote-service"); + span.Tags.SetTag("db.instance", "i-1234"); + span.Tags.SetTag("db.system", "postgres"); + + // Keys must be pre-sorted (matching agent behavior) + List peerTagKeys = [new("db.instance"), new("db.system"), new("peer.service")]; + var key = aggregator.BuildKey(span, peerTagKeys, out _); + + key.PeerTagsHash.Should().Be(9894752672193411515UL); + } + + [Fact] + public async Task PeerTagsHash_MatchesGoAgent_ConsumerMessagingTags() + { + // Golden value from Go agent's TestNewAggregation in aggregation_test.go: + // messaging.destination:topic-foo, messaging.system:kafka → hash 0xf5eeb51fbe7929b4 + // https://github.com/DataDog/datadog-agent/blob/4c45a7cf23b97bf6b904565f88d16e73da83842a/pkg/trace/stats/aggregation_test.go + var start = DateTimeOffset.UtcNow; + await using var aggregator = new StatsAggregator(Mock.Of(), GetSettings(), Mock.Of(), isOtlp: false); + + var span = CreateTopLevelSpan(start, "svc"); + span.SetTag(Tags.SpanKind, SpanKinds.Consumer); + span.Tags.SetTag("messaging.destination", "topic-foo"); + span.Tags.SetTag("messaging.system", "kafka"); + + List peerTagKeys = [new("db.instance"), new("db.system"), new("messaging.destination"), new("messaging.system")]; + var key = aggregator.BuildKey(span, peerTagKeys, out _); + + key.PeerTagsHash.Should().Be(0xf5eeb51fbe7929b4UL); + } + + [Fact] + public async Task PeerTagsHash_MatchesGoAgent_EmptyTagsSkipped() + { + // Same hash as single tag — empty db.instance and db.system values are skipped + // https://github.com/DataDog/datadog-agent/blob/4c45a7cf23b97bf6b904565f88d16e73da83842a/pkg/trace/stats/aggregation_test.go + var start = DateTimeOffset.UtcNow; + await using var aggregator = new StatsAggregator(Mock.Of(), GetSettings(), Mock.Of(), isOtlp: false); + + var span = CreateTopLevelSpan(start, "svc"); + span.SetTag(Tags.SpanKind, SpanKinds.Client); + span.Tags.SetTag("peer.service", "remote-service"); + span.Tags.SetTag("db.instance", string.Empty); + span.Tags.SetTag("db.system", string.Empty); + + List peerTagKeys = [new("db.instance"), new("db.system"), new("peer.service")]; + var key = aggregator.BuildKey(span, peerTagKeys, out _); + + key.PeerTagsHash.Should().Be(3430395298086625290UL); + } + + [Fact] + public async Task PeerTagsHash_MatchesEncodedPeerTags_MultipleTags() + { + // Verify that the fast-path hash from BuildKey matches + // what you'd get by hashing the GetEncodedPeerTags output directly + var start = DateTimeOffset.UtcNow; + await using var aggregator = new StatsAggregator(Mock.Of(), GetSettings(), Mock.Of(), isOtlp: false); + + var span = CreateTopLevelSpan(start, "svc"); + span.SetTag(Tags.SpanKind, SpanKinds.Client); + span.Tags.SetTag("peer.service", "remote-service"); + span.Tags.SetTag("db.instance", "i-1234"); + span.Tags.SetTag("db.system", "postgres"); + + List peerTagKeys = [new("db.instance"), new("db.system"), new("peer.service")]; + var key = aggregator.BuildKey(span, peerTagKeys, out var peerTagResults); + var encodedTags = StatsAggregator.GetEncodedPeerTags(span, peerTagKeys, in peerTagResults); + + // Hash the encoded tags the same way the Go agent does: + // FNV-1a of each tag's bytes, chained with a [0] separator + var expectedHash = FnvHash64.GenerateHash(encodedTags[0], FnvHash64.Version.V1A); + for (var i = 1; i < encodedTags.Count; i++) + { + expectedHash = FnvHash64.GenerateHash(new byte[] { 0 }, FnvHash64.Version.V1A, expectedHash); + expectedHash = FnvHash64.GenerateHash(encodedTags[i], FnvHash64.Version.V1A, expectedHash); + } + + key.PeerTagsHash.Should().Be(expectedHash); + } + + [Fact] + public async Task PeerTagsHash_MatchesEncodedPeerTags_BaseService() + { + // Verify that the fast-path hash from BuildKey matches the encoded base service tag + var start = DateTimeOffset.UtcNow; + await using var aggregator = new StatsAggregator(Mock.Of(), GetSettings(), Mock.Of(), isOtlp: false); + + var span = CreateTopLevelSpan(start, "svc"); + span.Tags.SetTag(Tags.BaseService, "my-base-service"); + + List peerTagKeys = [new("peer.service")]; + var key = aggregator.BuildKey(span, peerTagKeys, out var peerTagResults); + var encodedTags = StatsAggregator.GetEncodedPeerTags(span, peerTagKeys, in peerTagResults); + + encodedTags.Should().HaveCount(1); + var expectedHash = FnvHash64.GenerateHash(encodedTags[0], FnvHash64.Version.V1A); + + key.PeerTagsHash.Should().Be(expectedHash); + } + + [Fact] + public async Task PeerTagsHash_NoMatchingTags_ReturnsZero() + { + var start = DateTimeOffset.UtcNow; + await using var aggregator = new StatsAggregator(Mock.Of(), GetSettings(), Mock.Of(), isOtlp: false); + + var span = CreateTopLevelSpan(start, "svc"); + span.SetTag(Tags.SpanKind, SpanKinds.Client); + // No peer tags set on the span + + List peerTagKeys = [new("peer.service")]; + var key = aggregator.BuildKey(span, peerTagKeys, out _); + + key.PeerTagsHash.Should().Be(0UL); + } + /// /// Creates a top-level span with a TraceContext (required by GetWeight). /// diff --git a/tracer/test/Datadog.Trace.Tests/Agent/TraceFilterTests.cs b/tracer/test/Datadog.Trace.Tests/Agent/TraceFilterTests.cs index 07601b1d26ef..59efe05b8f31 100644 --- a/tracer/test/Datadog.Trace.Tests/Agent/TraceFilterTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Agent/TraceFilterTests.cs @@ -114,7 +114,7 @@ public void FilterOrder_ResourceThenRejectThenRequire() FilterTagsReject: null, FilterTagsRegexRequire: null, FilterTagsRegexReject: null, - IgnoreResources: ["GET /healthcheck"]); + IgnoreResourcesRegex: ["GET /healthcheck"]); var filter = new TraceFilter(config); // Even with correct required tag, resource reject wins