From 37164987c31377b942ac3e8d4cfba346a2455ccc Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Mon, 28 Sep 2026 18:20:20 +0000 Subject: [PATCH 01/15] feat(ffe): capture evaluation-time privacy consent --- .../Datadog.Trace/FeatureFlags/Evaluation.cs | 9 +- .../FeatureFlags/FeatureFlagMetadataKeys.cs | 1 + .../FeatureFlags/FeatureFlagsEvaluator.cs | 373 +++++++++--------- .../FeatureFlags/FeatureFlagsModule.cs | 2 +- .../Rcm/Model/ServerConfiguration.cs | 40 ++ .../Model/StrictBooleanTrueJsonConverter.cs | 24 ++ .../FeatureFlagsPrivacyConsentTests.cs | 190 +++++++++ .../UfcConfigurationParserTests.cs | 25 ++ 8 files changed, 479 insertions(+), 185 deletions(-) create mode 100644 tracer/src/Datadog.Trace/FeatureFlags/Rcm/Model/StrictBooleanTrueJsonConverter.cs create mode 100644 tracer/test/Datadog.Trace.Tests/FeatureFlags/FeatureFlagsPrivacyConsentTests.cs diff --git a/tracer/src/Datadog.Trace/FeatureFlags/Evaluation.cs b/tracer/src/Datadog.Trace/FeatureFlags/Evaluation.cs index 6ea76f781430..145fc38465ca 100644 --- a/tracer/src/Datadog.Trace/FeatureFlags/Evaluation.cs +++ b/tracer/src/Datadog.Trace/FeatureFlags/Evaluation.cs @@ -27,5 +27,12 @@ internal sealed class Evaluation(string flagKey, object? value, EvaluationReason public string? Error { get; } = error; - public IDictionary? FlagMetadata { get; } = metadata; + public IDictionary? FlagMetadata { get; private set; } = metadata; + + internal Evaluation WithPrivacyConsent(bool consent) + { + var metadata = FlagMetadata ??= new Dictionary(); + metadata[FeatureFlagMetadataKeys.ObserveFullEvaluationData] = consent ? "true" : "false"; + return this; + } } diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagMetadataKeys.cs b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagMetadataKeys.cs index 120de8bb33ad..45f6da960480 100644 --- a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagMetadataKeys.cs +++ b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagMetadataKeys.cs @@ -11,5 +11,6 @@ internal static class FeatureFlagMetadataKeys { internal const string SplitSerialId = "__dd_split_serial_id"; internal const string DoLog = "__dd_do_log"; + internal const string ObserveFullEvaluationData = "__dd_observe_full_evaluation_data"; } } diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsEvaluator.cs b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsEvaluator.cs index c0b2c6d210a0..48072d1f4ed3 100644 --- a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsEvaluator.cs +++ b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsEvaluator.cs @@ -54,189 +54,9 @@ public FeatureFlagsEvaluator(ReportExposureDelegate? onExposureEvent, ServerConf public Evaluation Evaluate(string flagKey, ValueType resultType, object? defaultValue, EvaluationContext? context) { - try - { - var config = _config; - if (config == null) - { - return new Evaluation( - flagKey, - defaultValue, - EvaluationReason.Error, - error: "PROVIDER_NOT_READY", - metadata: new Dictionary - { - ["errorCode"] = "PROVIDER_NOT_READY" - }); - } - - Flag? flag = null; - var lookupResult = config.Flags?.Find(flagKey, out flag) ?? FlagLookupResult.NotFound; - if (lookupResult == FlagLookupResult.NotFound) - { - return new Evaluation( - flagKey, - defaultValue, - EvaluationReason.Error, - error: "FLAG_NOT_FOUND", - metadata: new Dictionary - { - ["errorCode"] = "FLAG_NOT_FOUND" - }); - } - - if (lookupResult == FlagLookupResult.Invalid || flag is null) - { - return new Evaluation( - flagKey, - defaultValue, - EvaluationReason.Error, - error: "PARSE_ERROR", - metadata: new Dictionary - { - ["errorCode"] = "PARSE_ERROR" - }); - } - - if (flag.Enabled != true) - { - return new Evaluation( - flagKey, - defaultValue, - EvaluationReason.Disabled); - } - - if (flag.VariationType != resultType) - { - return new Evaluation( - flagKey, - defaultValue, - EvaluationReason.Error, - error: "TYPE_MISMATCH", - metadata: new Dictionary - { - ["errorCode"] = "TYPE_MISMATCH" - }); - } - - if (flag.Allocations is null or { Count: 0 }) - { - return new Evaluation( - flagKey, - defaultValue, - EvaluationReason.Default); - } - - var now = DateTime.UtcNow; - var targetingKey = context?.TargetingKey; - - foreach (var allocation in flag.Allocations) - { - if (!IsAllocationActive(allocation, now)) - { - continue; - } - - // Track whether this allocation has targeting rules - var hadRules = allocation.Rules is { Count: > 0 }; - if (hadRules) - { - if (!EvaluateRules(allocation.Rules!, context)) - { - continue; - } - } - - if (allocation.Splits is { Count: > 0 }) - { - foreach (var split in allocation.Splits) - { - if (StringUtil.IsNullOrEmpty(split.VariationKey)) - { - throw new FormatException($"Empty variation key in allocation {allocation.Key}"); - } - - var allShardsMatch = true; - var hadShards = split.Shards is { Count: > 0 }; - if (hadShards) - { - foreach (var shard in split.Shards!) - { - if (!MatchesShard(shard, targetingKey)) - { - allShardsMatch = false; - break; - } - } - } - - if (allShardsMatch) - { - // Determine reason based on how the flag was resolved. - // - TargetingMatch: Allocation had targeting rules that matched - // - Default: A temporal allocation with one unsharded split matched - // - Split: Resolved via percentage split without targeting rules - // - Static: No rules, no shards - simple static value - var isTemporalDefault = !hadRules && - !hadShards && - allocation.Splits.Count == 1 && - (!StringUtil.IsNullOrEmpty(allocation.StartAt) || - !StringUtil.IsNullOrEmpty(allocation.EndAt)); - var reason = hadRules ? EvaluationReason.TargetingMatch - : isTemporalDefault ? EvaluationReason.Default - : hadShards ? EvaluationReason.Split - : EvaluationReason.Static; - - return ResolveVariant(flagKey, resultType, defaultValue, flag, split, allocation, reason, now, context); - } - } - } - } - - // No allocation / split matched – use default - return new Evaluation( - flagKey, - defaultValue, - EvaluationReason.Default); - } - catch (FormatException ex) - { - return new Evaluation( - flagKey, - defaultValue, - EvaluationReason.Error, - error: "PARSE_ERROR", - metadata: new Dictionary - { - ["errorCode"] = "PARSE_ERROR", - ["message"] = ex.Message - }); - } - catch (MissingTargetingKeyException) - { - return new Evaluation( - flagKey, - defaultValue, - EvaluationReason.Error, - error: "TARGETING_KEY_MISSING", - metadata: new Dictionary - { - ["errorCode"] = "TARGETING_KEY_MISSING" - }); - } - catch (Exception ex) - { - return new Evaluation( - flagKey, - defaultValue, - EvaluationReason.Error, - error: ex.Message, - metadata: new Dictionary - { - ["errorCode"] = "GENERAL", - ["message"] = ex.Message - }); - } + // Capture before evaluating: even an exception after a configuration update keeps this consent. + var consent = _config?.GetEvaluationConsent(flagKey) == true; + return EvaluateCore(flagKey, resultType, defaultValue, context).WithPrivacyConsent(consent); } private static bool IsAllocationActive(Allocation allocation, DateTime now) @@ -663,6 +483,193 @@ private static double ParseInteger(object value) return result; } + private Evaluation EvaluateCore(string flagKey, ValueType resultType, object? defaultValue, EvaluationContext? context) + { + try + { + var config = _config; + if (config == null) + { + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Error, + error: "PROVIDER_NOT_READY", + metadata: new Dictionary + { + ["errorCode"] = "PROVIDER_NOT_READY" + }); + } + + Flag? flag = null; + var lookupResult = config.Flags?.Find(flagKey, out flag) ?? FlagLookupResult.NotFound; + if (lookupResult == FlagLookupResult.NotFound) + { + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Error, + error: "FLAG_NOT_FOUND", + metadata: new Dictionary + { + ["errorCode"] = "FLAG_NOT_FOUND" + }); + } + + if (lookupResult == FlagLookupResult.Invalid || flag is null) + { + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Error, + error: "PARSE_ERROR", + metadata: new Dictionary + { + ["errorCode"] = "PARSE_ERROR" + }); + } + + if (flag.Enabled != true) + { + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Disabled); + } + + if (flag.VariationType != resultType) + { + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Error, + error: "TYPE_MISMATCH", + metadata: new Dictionary + { + ["errorCode"] = "TYPE_MISMATCH" + }); + } + + if (flag.Allocations is null or { Count: 0 }) + { + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Default); + } + + var now = DateTime.UtcNow; + var targetingKey = context?.TargetingKey; + + foreach (var allocation in flag.Allocations) + { + if (!IsAllocationActive(allocation, now)) + { + continue; + } + + // Track whether this allocation has targeting rules + var hadRules = allocation.Rules is { Count: > 0 }; + if (hadRules) + { + if (!EvaluateRules(allocation.Rules!, context)) + { + continue; + } + } + + if (allocation.Splits is { Count: > 0 }) + { + foreach (var split in allocation.Splits) + { + if (StringUtil.IsNullOrEmpty(split.VariationKey)) + { + throw new FormatException($"Empty variation key in allocation {allocation.Key}"); + } + + var allShardsMatch = true; + var hadShards = split.Shards is { Count: > 0 }; + if (hadShards) + { + foreach (var shard in split.Shards!) + { + if (!MatchesShard(shard, targetingKey)) + { + allShardsMatch = false; + break; + } + } + } + + if (allShardsMatch) + { + // Determine reason based on how the flag was resolved. + // - TargetingMatch: Allocation had targeting rules that matched + // - Default: A temporal allocation with one unsharded split matched + // - Split: Resolved via percentage split without targeting rules + // - Static: No rules, no shards - simple static value + var isTemporalDefault = !hadRules && + !hadShards && + allocation.Splits.Count == 1 && + (!StringUtil.IsNullOrEmpty(allocation.StartAt) || + !StringUtil.IsNullOrEmpty(allocation.EndAt)); + var reason = hadRules ? EvaluationReason.TargetingMatch + : isTemporalDefault ? EvaluationReason.Default + : hadShards ? EvaluationReason.Split + : EvaluationReason.Static; + + return ResolveVariant(flagKey, resultType, defaultValue, flag, split, allocation, reason, now, context); + } + } + } + } + + // No allocation / split matched – use default + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Default); + } + catch (FormatException ex) + { + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Error, + error: "PARSE_ERROR", + metadata: new Dictionary + { + ["errorCode"] = "PARSE_ERROR", + ["message"] = ex.Message + }); + } + catch (MissingTargetingKeyException) + { + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Error, + error: "TARGETING_KEY_MISSING", + metadata: new Dictionary + { + ["errorCode"] = "TARGETING_KEY_MISSING" + }); + } + catch (Exception ex) + { + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Error, + error: ex.Message, + metadata: new Dictionary + { + ["errorCode"] = "GENERAL", + ["message"] = ex.Message + }); + } + } + private Evaluation ResolveVariant( string flagKey, ValueType resultType, diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsModule.cs b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsModule.cs index d3acc1a37dbd..b6b96e7e94f3 100644 --- a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsModule.cs +++ b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsModule.cs @@ -321,7 +321,7 @@ internal Evaluation Evaluate(string flagKey, ValueType resultType, object? defau if (evaluator is null) { Log.Debug("FeatureFlagsModule::Evaluate -> Evaluator is null (no config received)"); - return new Evaluation(flagKey, defaultValue, EvaluationReason.Error, null, "PROVIDER_NOT_READY"); + return new Evaluation(flagKey, defaultValue, EvaluationReason.Error, null, "PROVIDER_NOT_READY").WithPrivacyConsent(false); } Log.Debug("FeatureFlagsModule::Evaluate -> Returning Evaluation"); diff --git a/tracer/src/Datadog.Trace/FeatureFlags/Rcm/Model/ServerConfiguration.cs b/tracer/src/Datadog.Trace/FeatureFlags/Rcm/Model/ServerConfiguration.cs index 897a1574e80d..26c50db3d9b7 100644 --- a/tracer/src/Datadog.Trace/FeatureFlags/Rcm/Model/ServerConfiguration.cs +++ b/tracer/src/Datadog.Trace/FeatureFlags/Rcm/Model/ServerConfiguration.cs @@ -6,12 +6,18 @@ #nullable enable using System; +using System.Collections.Generic; using Datadog.Trace.Vendors.Newtonsoft.Json; namespace Datadog.Trace.FeatureFlags.Rcm.Model; internal sealed class ServerConfiguration { + private Dictionary? _sourceConsent; + + [JsonConverter(typeof(StrictBooleanTrueJsonConverter))] + public bool ObserveFullEvaluationData { get; set; } + public string? CreatedAt { get; set; } public string? Format { get; set; } @@ -21,8 +27,31 @@ internal sealed class ServerConfiguration [JsonConverter(typeof(FlagCollectionJsonConverter))] public FlagCollection? Flags { get; set; } + internal bool GetEvaluationConsent(string flagKey) + { + if (_sourceConsent is null) + { + return ObserveFullEvaluationData; + } + + // A missing/invalid flag in a merged configuration has no consenting source. + return flagKey is not null && _sourceConsent.TryGetValue(flagKey, out var consent) && consent; + } + internal void Merge(ServerConfiguration other) { + if (_sourceConsent is null) + { + _sourceConsent = new Dictionary(StringComparer.Ordinal); + if (Flags is not null) + { + foreach (var pair in Flags.ValidFlags) + { + _sourceConsent[pair.Key] = ObserveFullEvaluationData; + } + } + } + if (other.CreatedAt is not null) { CreatedAt = other.CreatedAt; @@ -45,6 +74,17 @@ internal void Merge(ServerConfiguration other) if (other.Flags is not null) { + // Keep consent with the source that supplied each flag without modifying shared flags. + foreach (var pair in other.Flags.ValidFlags) + { + _sourceConsent[pair.Key] = other.GetEvaluationConsent(pair.Key); + } + + foreach (var key in other.Flags.InvalidFlagKeys) + { + _sourceConsent.Remove(key); + } + Flags.Merge(other.Flags); } } diff --git a/tracer/src/Datadog.Trace/FeatureFlags/Rcm/Model/StrictBooleanTrueJsonConverter.cs b/tracer/src/Datadog.Trace/FeatureFlags/Rcm/Model/StrictBooleanTrueJsonConverter.cs new file mode 100644 index 000000000000..9a81df854148 --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/Rcm/Model/StrictBooleanTrueJsonConverter.cs @@ -0,0 +1,24 @@ +// +// 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 Datadog.Trace.Vendors.Newtonsoft.Json; + +namespace Datadog.Trace.FeatureFlags.Rcm.Model; + +internal sealed class StrictBooleanTrueJsonConverter : JsonConverter +{ + public override bool ReadJson(JsonReader reader, Type objectType, bool existingValue, bool hasExistingValue, JsonSerializer serializer) + { + // Malformed consent protects data without rejecting otherwise usable flag configuration. + var consent = reader.TokenType == JsonToken.Boolean && reader.Value is true; + reader.Skip(); + return consent; + } + + public override void WriteJson(JsonWriter writer, bool value, JsonSerializer serializer) => writer.WriteValue(value); +} diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FeatureFlagsPrivacyConsentTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FeatureFlagsPrivacyConsentTests.cs new file mode 100644 index 000000000000..daf091a59a72 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FeatureFlagsPrivacyConsentTests.cs @@ -0,0 +1,190 @@ +// +// 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 System.Collections.Specialized; +using Datadog.Trace.Configuration; +using Datadog.Trace.FeatureFlags; +using Datadog.Trace.FeatureFlags.Rcm.Model; +using Datadog.Trace.TestHelpers; +using Xunit; +using ValueType = Datadog.Trace.FeatureFlags.ValueType; + +namespace Datadog.Trace.Tests.FeatureFlags; + +public class FeatureFlagsPrivacyConsentTests +{ + public static IEnumerable TerminalCases() + { + foreach (var consent in new[] { false, true }) + { + yield return new object?[] { consent, "static", "Static", null }; + yield return new object?[] { consent, "disabled", "Disabled", null }; + yield return new object?[] { consent, "null-allocations", "Default", null }; + yield return new object?[] { consent, "empty-allocations", "Default", null }; + yield return new object?[] { consent, "no-match", "Default", null }; + yield return new object?[] { consent, "missing", "Error", "FLAG_NOT_FOUND" }; + yield return new object?[] { consent, "invalid", "Error", "PARSE_ERROR" }; + yield return new object?[] { consent, "type-mismatch", "Error", "TYPE_MISMATCH" }; + yield return new object?[] { consent, "missing-variant", "Error", "PARSE_ERROR" }; + yield return new object?[] { consent, "format-exception", "Error", "PARSE_ERROR" }; + yield return new object?[] { consent, "general-exception", "Error", "GENERAL" }; + yield return new object?[] { consent, "missing-target", "Error", "TARGETING_KEY_MISSING" }; + } + } + + [Theory] + [MemberData(nameof(TerminalCases))] + public void EveryTerminalPathRetainsEvaluationConsent(bool consent, string scenario, string reason, string? errorCode) + { + var config = Configuration(consent); + var flag = config.Flags!["flag"]; + var key = "flag"; + switch (scenario) + { + case "disabled": + flag.Enabled = false; + break; + case "null-allocations": + flag.Allocations = null; + break; + case "empty-allocations": + flag.Allocations = new List(); + break; + case "no-match": + flag.Allocations![0].EndAt = "2000-01-01T00:00:00Z"; + break; + case "missing": + key = "missing"; + break; + case "invalid": + config.Flags.MarkInvalid("flag"); + break; + case "type-mismatch": + flag.VariationType = ValueType.Boolean; + break; + case "missing-variant": + flag.Variations!.Clear(); + break; + case "format-exception": + flag.Variations!["tracked"].Value = new CallbackValue(() => throw new FormatException("private-format-error")); + break; + case "general-exception": + flag.Variations!["tracked"].Value = new CallbackValue(() => throw new InvalidOperationException("private-general-error")); + break; + case "missing-target": + flag.Allocations![0].Splits![0].Shards = new List { new() { Salt = ".", TotalShards = 100, Ranges = new List { new() { Start = 0, End = 100 } } } }; + break; + } + + var result = new FeatureFlagsEvaluator(null, config).Evaluate(key, ValueType.String, "fallback", new EvaluationContext(scenario == "missing-target" ? null : "user")); + + Assert.Equal(reason, result.Reason.ToString()); + Assert.Equal(scenario == "static" ? "tracked-value" : "fallback", result.Value); + if (errorCode is not null) + { + Assert.Equal(errorCode, result.FlagMetadata!["errorCode"]); + } + + Assert.Equal(consent ? "true" : "false", result.FlagMetadata!["__dd_observe_full_evaluation_data"]); + } + + [Fact] + public void MissingConfigurationIsProtectedInBothEvaluatorAndModule() + { + using var module = Module(); + var direct = new FeatureFlagsEvaluator(null, null).Evaluate("flag", ValueType.String, "fallback", null); + var throughModule = module.Evaluate("flag", ValueType.String, "fallback", null, null); + foreach (var result in new[] { direct, throughModule }) + { + Assert.Equal("PROVIDER_NOT_READY", result.Error); + Assert.Equal("fallback", result.Value); + Assert.Equal("false", result.FlagMetadata!["__dd_observe_full_evaluation_data"]); + } + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void ConfigurationSwapDuringEvaluationCannotChangeItsConsent(bool originalConsent, bool throwAfterSwap) + { + using var module = Module(); + var original = Configuration(originalConsent); + var replacement = Configuration(!originalConsent); + original.Flags!["flag"].Variations!["tracked"].Value = new CallbackValue(() => + { + Assert.True(module.ApplyConfiguration(replacement)); + if (throwAfterSwap) + { + throw new InvalidOperationException("private-error-after-swap"); + } + }); + Assert.True(module.ApplyConfiguration(original)); + + var first = module.Evaluate("flag", ValueType.String, "fallback", "user", null); + var second = module.Evaluate("flag", ValueType.String, "fallback", "user", null); + + Assert.Equal(throwAfterSwap ? "fallback" : "callback-value", first.Value); + Assert.Equal(originalConsent ? "true" : "false", first.FlagMetadata!["__dd_observe_full_evaluation_data"]); + Assert.Equal("tracked-value", second.Value); + Assert.Equal(originalConsent ? "false" : "true", second.FlagMetadata!["__dd_observe_full_evaluation_data"]); + if (throwAfterSwap) + { + Assert.Equal("GENERAL", first.FlagMetadata["errorCode"]); + Assert.Equal("private-error-after-swap", first.Error); + } + } + + [Fact] + public void MergedFlagsKeepSourceConsentWithoutMutatingTheirOriginalConfigurations() + { + var consented = Configuration(true); + var protectedConfig = Configuration(false); + protectedConfig.Flags!.Add("protected", protectedConfig.Flags["flag"]); + protectedConfig.Flags.MarkInvalid("invalid"); + var merged = new ServerConfiguration(); + merged.Merge(protectedConfig); + merged.Merge(consented); + var evaluator = new FeatureFlagsEvaluator(null, merged); + + Assert.Equal("true", Evaluate("flag").FlagMetadata!["__dd_observe_full_evaluation_data"]); + Assert.Equal("false", Evaluate("protected").FlagMetadata!["__dd_observe_full_evaluation_data"]); + Assert.Equal("false", Evaluate("missing").FlagMetadata!["__dd_observe_full_evaluation_data"]); + Assert.Equal("false", Evaluate("invalid").FlagMetadata!["__dd_observe_full_evaluation_data"]); + Assert.Equal("true", new FeatureFlagsEvaluator(null, consented).Evaluate("flag", ValueType.String, "fallback", new EvaluationContext("user")).FlagMetadata!["__dd_observe_full_evaluation_data"]); + Assert.Equal("false", new FeatureFlagsEvaluator(null, protectedConfig).Evaluate("flag", ValueType.String, "fallback", new EvaluationContext("user")).FlagMetadata!["__dd_observe_full_evaluation_data"]); + + Evaluation Evaluate(string key) => evaluator.Evaluate(key, ValueType.String, "fallback", new EvaluationContext("user")); + } + + private static ServerConfiguration Configuration(bool consent) + { + var flag = FeatureFlagsHelpers.CreateExposureFlag(); + flag.Allocations![0].DoLog = false; + return new ServerConfiguration + { + ObserveFullEvaluationData = consent, + Flags = new FlagCollection { ["flag"] = flag }, + }; + } + + private static FeatureFlagsModule Module() => FeatureFlagsModule.Create( + new TracerSettings(new NameValueConfigurationSource(new NameValueCollection { { ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSource, "remote_config" } })), + new MockRcmSubscriptionManager())!; + + private sealed class CallbackValue(Action callback) + { + public override string ToString() + { + callback(); + return "callback-value"; + } + } +} diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/UfcConfigurationParserTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/UfcConfigurationParserTests.cs index 939151053415..ad058384e428 100644 --- a/tracer/test/Datadog.Trace.Tests/FeatureFlags/UfcConfigurationParserTests.cs +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/UfcConfigurationParserTests.cs @@ -37,6 +37,31 @@ public void ParsesValidEnvelope() configuration.Should().NotBeNull(); configuration!.Environment!.Name.Should().Be("production"); configuration.Flags.Should().BeEmpty(); + configuration.ObserveFullEvaluationData.Should().BeFalse(); + } + + [Theory] + [InlineData("true", true)] + [InlineData("false", false)] + [InlineData("null", false)] + [InlineData("1", false)] + [InlineData("1.0", false)] + [InlineData("\"true\"", false)] + [InlineData("\"TRUE\"", false)] + [InlineData("{}", false)] + [InlineData("[]", false)] + [InlineData("{\"nested\":[true]}", false)] + [InlineData("[true,{\"nested\":true}]", false)] + public void ConsentDoesNotRejectUsableConfiguration(string token, bool expected) + { + var body = ValidEnvelope.Replace("\"format\": \"SERVER\"", "\"observeFullEvaluationData\": " + token + ", \"format\": \"SERVER\""); + + Parse(body, out var configuration, out var error).Should().BeTrue(); + + error.Should().BeNull(); + configuration!.ObserveFullEvaluationData.Should().Be(expected); + configuration!.Format.Should().Be("SERVER"); + configuration!.Environment!.Name.Should().Be("production"); } [Theory] From 1eb4816e6f691c6d26750a52b1d027cee6f8bcbb Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Mon, 28 Sep 2026 18:53:09 +0000 Subject: [PATCH 02/15] feat(ffe): capture bounded EVP observations in the provider --- Datadog.Trace.sln | 7 + .../Datadog.FeatureFlags.OpenFeature.csproj | 2 + .../DatadogProvider.cs | 9 +- .../FlagEvalEVPHook.cs | 109 ++++++ .../FlagEvaluationContextSnapshot.cs | 158 +++++++++ .../Sdk/FeatureFlagsSdk.cs | 33 +- .../FeatureFlags/FeatureFlagMetadataKeys.cs | 1 + .../FlagEvaluation/ContextOmissionReason.cs | 23 ++ ...adog.FeatureFlags.OpenFeature.Tests.csproj | 9 + .../FeatureFlagsSdkTests.cs | 100 ++++++ .../FlagEvalEVPHookTests.cs | 323 ++++++++++++++++++ .../FlagEvaluationContextSnapshotTests.cs | 168 +++++++++ 12 files changed, 932 insertions(+), 10 deletions(-) create mode 100644 tracer/src/Datadog.FeatureFlags.OpenFeature/FlagEvalEVPHook.cs create mode 100644 tracer/src/Datadog.FeatureFlags.OpenFeature/FlagEvaluationContextSnapshot.cs create mode 100644 tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/ContextOmissionReason.cs create mode 100644 tracer/test/Datadog.FeatureFlags.OpenFeature.Tests/Datadog.FeatureFlags.OpenFeature.Tests.csproj create mode 100644 tracer/test/Datadog.FeatureFlags.OpenFeature.Tests/FeatureFlagsSdkTests.cs create mode 100644 tracer/test/Datadog.FeatureFlags.OpenFeature.Tests/FlagEvalEVPHookTests.cs create mode 100644 tracer/test/Datadog.FeatureFlags.OpenFeature.Tests/FlagEvaluationContextSnapshotTests.cs diff --git a/Datadog.Trace.sln b/Datadog.Trace.sln index 8626d8899e50..e5ce82cfc8ac 100644 --- a/Datadog.Trace.sln +++ b/Datadog.Trace.sln @@ -657,6 +657,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Datadog.AutoInstrumentation EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Datadog.AutoInstrumentation.Generator.Cli", "tracer\src\Datadog.AutoInstrumentation.Generator.Cli\Datadog.AutoInstrumentation.Generator.Cli.csproj", "{8D722514-8BAD-4069-84D0-15797026E0ED}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Datadog.FeatureFlags.OpenFeature.Tests", "tracer\test\Datadog.FeatureFlags.OpenFeature.Tests\Datadog.FeatureFlags.OpenFeature.Tests.csproj", "{372B229E-7999-4AB6-903E-BDAF958F1696}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1609,6 +1611,10 @@ Global {8D722514-8BAD-4069-84D0-15797026E0ED}.Debug|Any CPU.Build.0 = Debug|Any CPU {8D722514-8BAD-4069-84D0-15797026E0ED}.Release|Any CPU.ActiveCfg = Release|Any CPU {8D722514-8BAD-4069-84D0-15797026E0ED}.Release|Any CPU.Build.0 = Release|Any CPU + {372B229E-7999-4AB6-903E-BDAF958F1696}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {372B229E-7999-4AB6-903E-BDAF958F1696}.Debug|Any CPU.Build.0 = Debug|Any CPU + {372B229E-7999-4AB6-903E-BDAF958F1696}.Release|Any CPU.ActiveCfg = Release|Any CPU + {372B229E-7999-4AB6-903E-BDAF958F1696}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1871,6 +1877,7 @@ Global {08C6510D-42DC-C8B4-CC79-26349F2D6DBB} = {BAF8F246-3645-42AD-B1D0-0F7EAFBAB34A} {B3A07CA3-3536-4D43-81A3-A5A73FB76F22} = {9E5F0022-0A50-40BF-AC6A-C3078585ECAB} {8D722514-8BAD-4069-84D0-15797026E0ED} = {9E5F0022-0A50-40BF-AC6A-C3078585ECAB} + {372B229E-7999-4AB6-903E-BDAF958F1696} = {8CEC2042-F11C-49F5-A674-2355793B600A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {160A1D00-1F5B-40F8-A155-621B4459D78F} diff --git a/tracer/src/Datadog.FeatureFlags.OpenFeature/Datadog.FeatureFlags.OpenFeature.csproj b/tracer/src/Datadog.FeatureFlags.OpenFeature/Datadog.FeatureFlags.OpenFeature.csproj index 799ef1df16dd..113c26b7c7bf 100644 --- a/tracer/src/Datadog.FeatureFlags.OpenFeature/Datadog.FeatureFlags.OpenFeature.csproj +++ b/tracer/src/Datadog.FeatureFlags.OpenFeature/Datadog.FeatureFlags.OpenFeature.csproj @@ -24,12 +24,14 @@ + + diff --git a/tracer/src/Datadog.FeatureFlags.OpenFeature/DatadogProvider.cs b/tracer/src/Datadog.FeatureFlags.OpenFeature/DatadogProvider.cs index 1f1e2fdd70e4..7571f7ea7b99 100644 --- a/tracer/src/Datadog.FeatureFlags.OpenFeature/DatadogProvider.cs +++ b/tracer/src/Datadog.FeatureFlags.OpenFeature/DatadogProvider.cs @@ -40,6 +40,7 @@ public sealed class DatadogProvider : global::OpenFeature.FeatureProvider, IDisp // Span-enrichment hook is constructed ONLY when the gate is on; null otherwise so // nothing is allocated/registered when the feature is disabled. private readonly SpanEnrichmentHook? _spanEnrichmentHook; + private readonly FlagEvalEVPHook _evpHook = new(); private int _status = StatusInitializing; @@ -275,17 +276,17 @@ public override IImmutableList GetProviderHooks() #if NET6_0_OR_GREATER if (_spanEnrichmentHook is not null) { - return ImmutableList.Create(_metricsHook, _spanEnrichmentHook); + return ImmutableList.Create(_metricsHook, _spanEnrichmentHook, _evpHook); } - return ImmutableList.Create(_metricsHook); + return ImmutableList.Create(_metricsHook, _evpHook); #else if (_spanEnrichmentHook is not null) { - return ImmutableList.Create(_spanEnrichmentHook); + return ImmutableList.Create(_spanEnrichmentHook, _evpHook); } - return ImmutableList.Empty; + return ImmutableList.Create(_evpHook); #endif } diff --git a/tracer/src/Datadog.FeatureFlags.OpenFeature/FlagEvalEVPHook.cs b/tracer/src/Datadog.FeatureFlags.OpenFeature/FlagEvalEVPHook.cs new file mode 100644 index 000000000000..530c4d3c8307 --- /dev/null +++ b/tracer/src/Datadog.FeatureFlags.OpenFeature/FlagEvalEVPHook.cs @@ -0,0 +1,109 @@ +// +// 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 System.Threading; +using System.Threading.Tasks; +using Datadog.Trace.FeatureFlags; +using Datadog.Trace.FeatureFlags.FlagEvaluation; +using OpenFeature; +using OpenFeature.Constant; +using OpenFeature.Model; + +namespace Datadog.FeatureFlags.OpenFeature; + +internal sealed class FlagEvalEVPHook : Hook +{ + private readonly Func _canEnqueue; + private readonly Action?, int> _enqueue; + private readonly Action _recordError; + private readonly CaptureSnapshot _capture; + + internal FlagEvalEVPHook() + : this(FeatureFlagsSdk.CanEnqueueEVP, FeatureFlagsSdk.EnqueueEVP, FeatureFlagsSdk.RecordEVPHookError, FlagEvaluationContextSnapshot.Capture) + { + } + + internal FlagEvalEVPHook( + Func canEnqueue, + Action?, int> enqueue, + Action recordError, + CaptureSnapshot capture) + { + _canEnqueue = canEnqueue; + _enqueue = enqueue; + _recordError = recordError; + _capture = capture; + } + + internal delegate IReadOnlyDictionary CaptureSnapshot(EvaluationContext? context, out int omissionReasons); + + public override ValueTask FinallyAsync(HookContext context, FlagEvaluationDetails details, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + try + { + if (!_canEnqueue()) + { + return default; + } + + var metadata = details.FlagMetadata; + var consent = metadata?.GetBool(FeatureFlagMetadataKeys.ObserveFullEvaluationData) == true; + var allocationKey = metadata?.GetString("__dd_allocation_key"); + var timestamp = metadata?.GetDouble(FeatureFlagMetadataKeys.EvaluationTimestampMs); + // Missing metadata is possible when OpenFeature terminates before provider resolution. + var evalTimeMs = timestamp is >= 0 and <= 253402300799999d && timestamp == Math.Truncate(timestamp.Value) + ? (long)timestamp.Value + : DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var errorCode = ToErrorCode(details.ErrorType); + var targetingKey = context.EvaluationContext?.TargetingKey; + IReadOnlyDictionary? attributes = null; + var omissionReasons = 0; + if (consent) + { + try + { + attributes = _capture(context.EvaluationContext, out omissionReasons); + } + catch (Exception) + { + // Preserve the observation, but discard context and never expose exception text. + omissionReasons = (int)ContextOmissionReason.SnapshotError; + } + } + + _enqueue(context.FlagKey, details.Variant, allocationKey, targetingKey, evalTimeMs, consent, errorCode, attributes, omissionReasons); + } + catch (Exception) + { + try + { + _recordError(); + } + catch (Exception) + { + // Telemetry failures must not change the customer's evaluation. + } + } + + return default; + } + + private static string? ToErrorCode(ErrorType error) => error switch + { + ErrorType.None => null, + ErrorType.FlagNotFound => "FLAG_NOT_FOUND", + ErrorType.InvalidContext => "INVALID_CONTEXT", + ErrorType.ParseError => "PARSE_ERROR", + ErrorType.ProviderFatal => "PROVIDER_FATAL", + ErrorType.ProviderNotReady => "PROVIDER_NOT_READY", + ErrorType.TargetingKeyMissing => "TARGETING_KEY_MISSING", + ErrorType.TypeMismatch => "TYPE_MISMATCH", + _ => "GENERAL", + }; +} diff --git a/tracer/src/Datadog.FeatureFlags.OpenFeature/FlagEvaluationContextSnapshot.cs b/tracer/src/Datadog.FeatureFlags.OpenFeature/FlagEvaluationContextSnapshot.cs new file mode 100644 index 000000000000..e23972e8cabc --- /dev/null +++ b/tracer/src/Datadog.FeatureFlags.OpenFeature/FlagEvaluationContextSnapshot.cs @@ -0,0 +1,158 @@ +// +// 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 System.Collections.Immutable; +using System.Globalization; +using Datadog.Trace.FeatureFlags.FlagEvaluation; +using OpenFeature.Model; + +namespace Datadog.FeatureFlags.OpenFeature; + +internal static class FlagEvaluationContextSnapshot +{ + internal static IReadOnlyDictionary Capture(EvaluationContext? context, out int omissionReasons) + { + var snapshot = new Snapshot(); + if (context is not null) + { + snapshot.VisitMap(context.AsDictionary(), string.Empty, 0); + } + + omissionReasons = (int)snapshot.Reasons; + return snapshot.Fields; + } + + private sealed class Snapshot + { + private const int MaxFields = 256; + private const int MaxTextLength = 256; + private const int MaxContainerItems = 256; + private const int MaxDepth = 4; + private const int MaxVisits = 1280; + private int _visits; + private bool _stopped; + + internal Dictionary Fields { get; } = new(StringComparer.Ordinal); + + internal ContextOmissionReason Reasons { get; private set; } + + internal void VisitMap(IImmutableDictionary map, string prefix, int depth) + { + if (map.Count > MaxContainerItems) + { + Reasons |= ContextOmissionReason.MaxStructureProperties; + } + + var count = Math.Min(map.Count, MaxContainerItems); + // Bound enumeration without collecting/sorting the input. Rebuilt oversized maps with + // colliding key hashes can select different subsets; evaluation results are unaffected. + using var iterator = map.GetEnumerator(); + for (var i = 0; i < count && TryVisit() && iterator.MoveNext(); i++) + { + var pair = iterator.Current; + + if (depth == 0 && pair.Key == "targetingKey") + { + continue; + } + + if (pair.Key.Length > MaxTextLength || (depth != 0 && prefix.Length + 1 + pair.Key.Length > MaxTextLength)) + { + Reasons |= ContextOmissionReason.MaxKeyLength; + continue; + } + + var key = depth == 0 ? pair.Key : prefix + "." + pair.Key; + VisitValue(key, pair.Value, depth + 1); + } + } + + private bool TryVisit() + { + if (_stopped) + { + return false; + } + + if (Fields.Count == MaxFields) + { + Reasons |= ContextOmissionReason.MaxContextFields; + _stopped = true; + return false; + } + + if (_visits == MaxVisits) + { + Reasons |= ContextOmissionReason.MaxVisitedNodes; + _stopped = true; + return false; + } + + _visits++; + return true; + } + + private void VisitValue(string key, Value? value, int depth) + { + if (depth > MaxDepth) + { + Reasons |= ContextOmissionReason.MaxSnapshotDepth; + return; + } + + if (value?.AsStructure is { } structure) + { + VisitMap(structure.AsDictionary(), key, depth); + } + else if (value?.AsList is { } list) + { + if (list.Count > MaxContainerItems) + { + Reasons |= ContextOmissionReason.MaxListElements; + } + + var count = Math.Min(list.Count, MaxContainerItems); + for (var i = 0; i < count && TryVisit(); i++) + { + var childKey = key + "." + i.ToString(CultureInfo.InvariantCulture); + if (childKey.Length > MaxTextLength) + { + Reasons |= ContextOmissionReason.MaxKeyLength; + continue; + } + + VisitValue(childKey, list[i], depth + 1); + } + } + else if (value?.AsString is { } text) + { + if (text.Length > MaxTextLength) + { + Reasons |= ContextOmissionReason.MaxValueLength; + } + else + { + Fields[key] = text; + } + } + else if (value?.AsBoolean is { } boolean) + { + Fields[key] = boolean; + } + else if (value?.AsDouble is { } number && !double.IsNaN(number) && !double.IsInfinity(number)) + { + Fields[key] = number; + } + else + { + Reasons |= ContextOmissionReason.UnsupportedValue; + } + } + } +} diff --git a/tracer/src/Datadog.FeatureFlags.OpenFeature/Sdk/FeatureFlagsSdk.cs b/tracer/src/Datadog.FeatureFlags.OpenFeature/Sdk/FeatureFlagsSdk.cs index 7287236da440..b766d21e0706 100644 --- a/tracer/src/Datadog.FeatureFlags.OpenFeature/Sdk/FeatureFlagsSdk.cs +++ b/tracer/src/Datadog.FeatureFlags.OpenFeature/Sdk/FeatureFlagsSdk.cs @@ -34,6 +34,19 @@ internal static class FeatureFlagsSdk [MethodImpl(MethodImplOptions.NoInlining)] internal static bool IsSpanEnrichmentEnabled() => false; + [MethodImpl(MethodImplOptions.NoInlining)] + internal static bool CanEnqueueEVP() => false; + + [MethodImpl(MethodImplOptions.NoInlining)] + internal static void EnqueueEVP(string flagKey, string? variant, string? allocationKey, string? targetingKey, long evalTimeMs, bool consent, string? errorCode, IReadOnlyDictionary? attrs, int omissionReasons) + { + } + + [MethodImpl(MethodImplOptions.NoInlining)] + internal static void RecordEVPHookError() + { + } + /// /// Gets a value indicating whether flag configuration is currently held, so the provider can /// resolve flags. Goes back to false when configuration is withdrawn. @@ -82,8 +95,12 @@ internal static void AccumulateSpanEnrichment(long? serialId, bool doLog, string { } - public static ResolutionDetails Resolve(string flagKey, Trace.FeatureFlags.ValueType targetType, T defaultValue, EvaluationContext? context) => - GetResolutionDetails(flagKey, defaultValue, Evaluate(flagKey, targetType, defaultValue, context?.TargetingKey, GetContextAttributes(context))); + public static ResolutionDetails Resolve(string flagKey, Trace.FeatureFlags.ValueType targetType, T defaultValue, EvaluationContext? context) + { + var evalTimeMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var evaluation = Evaluate(flagKey, targetType, defaultValue, context?.TargetingKey, GetContextAttributes(context)); + return GetResolutionDetails(flagKey, defaultValue, evaluation, evalTimeMs); + } private static IDictionary? GetContextAttributes(EvaluationContext? context) { @@ -104,7 +121,7 @@ public static ResolutionDetails Resolve(string flagKey, Trace.FeatureFlags _ => value.AsObject, }; - private static ResolutionDetails GetResolutionDetails(string flagKey, T defaultValue, Datadog.Trace.FeatureFlags.IEvaluation? evaluation) + internal static ResolutionDetails GetResolutionDetails(string flagKey, T defaultValue, Datadog.Trace.FeatureFlags.IEvaluation? evaluation, long evalTimeMs) { // OpenFeature substitutes the caller's default only when a provider throws. This provider // reports errors as details instead, so every error must carry the default itself. @@ -117,7 +134,7 @@ private static ResolutionDetails GetResolutionDetails(string flagKey, T de default, default, "FeatureFlagsSdk is disabled", - null); + ToMetadata(null, evalTimeMs)); } var errorType = ToErrorType(evaluation.Reason, evaluation.Error); @@ -131,7 +148,7 @@ private static ResolutionDetails GetResolutionDetails(string flagKey, T de ReasonToLowerSnakeCase(evaluation.Reason), evaluation.Variant, evaluation.Error, - ToMetadata(evaluation.FlagMetadata)); + ToMetadata(evaluation.FlagMetadata, evalTimeMs)); return res; } @@ -166,9 +183,13 @@ private static ErrorType ToErrorType(Datadog.Trace.FeatureFlags.EvaluationReason _ => "unknown" }; - private static ImmutableMetadata ToMetadata(IDictionary? metadata) + private static ImmutableMetadata ToMetadata(IDictionary? metadata, long evalTimeMs) { var dic = (metadata ?? new Dictionary()).ToDictionary(p => p.Key, p => (object)p.Value); + dic[FeatureFlagMetadataKeys.ObserveFullEvaluationData] = + metadata is not null && metadata.TryGetValue(FeatureFlagMetadataKeys.ObserveFullEvaluationData, out var consent) && consent == "true"; + // OpenFeature exposes numeric metadata as int or double. Epoch milliseconds are exact as a double. + dic[FeatureFlagMetadataKeys.EvaluationTimestampMs] = (double)evalTimeMs; return new ImmutableMetadata(dic); } diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagMetadataKeys.cs b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagMetadataKeys.cs index 45f6da960480..90002fe4c636 100644 --- a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagMetadataKeys.cs +++ b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagMetadataKeys.cs @@ -12,5 +12,6 @@ internal static class FeatureFlagMetadataKeys internal const string SplitSerialId = "__dd_split_serial_id"; internal const string DoLog = "__dd_do_log"; internal const string ObserveFullEvaluationData = "__dd_observe_full_evaluation_data"; + internal const string EvaluationTimestampMs = "__dd_eval_timestamp_ms"; } } diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/ContextOmissionReason.cs b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/ContextOmissionReason.cs new file mode 100644 index 000000000000..9e8d3096fd26 --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/ContextOmissionReason.cs @@ -0,0 +1,23 @@ +// +// 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; + +namespace Datadog.Trace.FeatureFlags.FlagEvaluation; + +[Flags] +internal enum ContextOmissionReason +{ + None = 0, + MaxContextFields = 1, + MaxKeyLength = 2, + MaxValueLength = 4, + MaxListElements = 8, + MaxStructureProperties = 16, + MaxSnapshotDepth = 32, + MaxVisitedNodes = 64, + UnsupportedValue = 128, + SnapshotError = 256, +} diff --git a/tracer/test/Datadog.FeatureFlags.OpenFeature.Tests/Datadog.FeatureFlags.OpenFeature.Tests.csproj b/tracer/test/Datadog.FeatureFlags.OpenFeature.Tests/Datadog.FeatureFlags.OpenFeature.Tests.csproj new file mode 100644 index 000000000000..031a8fba23f3 --- /dev/null +++ b/tracer/test/Datadog.FeatureFlags.OpenFeature.Tests/Datadog.FeatureFlags.OpenFeature.Tests.csproj @@ -0,0 +1,9 @@ + + + + false + + + + + diff --git a/tracer/test/Datadog.FeatureFlags.OpenFeature.Tests/FeatureFlagsSdkTests.cs b/tracer/test/Datadog.FeatureFlags.OpenFeature.Tests/FeatureFlagsSdkTests.cs new file mode 100644 index 000000000000..7c0ae7127ef6 --- /dev/null +++ b/tracer/test/Datadog.FeatureFlags.OpenFeature.Tests/FeatureFlagsSdkTests.cs @@ -0,0 +1,100 @@ +// +// 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.FeatureFlags; +using FluentAssertions; +using OpenFeature.Constant; +using Xunit; + +namespace Datadog.FeatureFlags.OpenFeature.Tests; + +public class FeatureFlagsSdkTests +{ + [Theory] + [InlineData(null, false)] + [InlineData("false", false)] + [InlineData("true", true)] + [InlineData("True", false)] + [InlineData("1", false)] + [InlineData(" true ", false)] + public void ConsentIsStrictAndTyped(string? consent, bool expected) + { + var metadata = new Dictionary { [FeatureFlagMetadataKeys.DoLog] = "true" }; + if (consent is not null) + { + metadata[FeatureFlagMetadataKeys.ObserveFullEvaluationData] = consent; + } + + var result = FeatureFlagsSdk.GetResolutionDetails("flag", false, new TestEvaluation(metadata), 1234); + + result.FlagMetadata.Should().NotBeNull(); + result.FlagMetadata!.GetBool(FeatureFlagMetadataKeys.ObserveFullEvaluationData).Should().Be(expected); + result.FlagMetadata.GetString(FeatureFlagMetadataKeys.DoLog).Should().Be("true"); + result.FlagMetadata.GetDouble("__dd_eval_timestamp_ms").Should().Be(1234); + metadata.Should().NotContainKey("__dd_eval_timestamp_ms"); + } + + [Fact] + public void OldTracerWithoutMetadataDefaultsToProtected() + { + var result = FeatureFlagsSdk.GetResolutionDetails("flag", false, new TestEvaluation(null), 1234); + + result.Value.Should().BeTrue(); + result.FlagMetadata!.GetBool(FeatureFlagMetadataKeys.ObserveFullEvaluationData).Should().BeFalse(); + result.FlagMetadata.GetDouble("__dd_eval_timestamp_ms").Should().Be(1234); + } + + [Fact] + public void UninstrumentedProviderPreservesDefaultAndCapturesTimestamp() + { + var before = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var result = FeatureFlagsSdk.Resolve("flag", Trace.FeatureFlags.ValueType.Boolean, false, null); + var after = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + result.Value.Should().BeFalse(); + result.ErrorType.Should().Be(ErrorType.ProviderNotReady); + result.FlagMetadata!.GetBool(FeatureFlagMetadataKeys.ObserveFullEvaluationData).Should().BeFalse(); + result.FlagMetadata.GetDouble("__dd_eval_timestamp_ms").Should().BeInRange(before, after); + } + + [Theory] + [InlineData("FLAG_NOT_FOUND")] + [InlineData("INVALID_CONTEXT")] + [InlineData("PARSE_ERROR")] + [InlineData("PROVIDER_FATAL")] + [InlineData("PROVIDER_NOT_READY")] + [InlineData("TARGETING_KEY_MISSING")] + [InlineData("TYPE_MISMATCH")] + [InlineData("GENERAL")] + public void ErrorsPreserveCallerDefaultAndEvaluationMetadata(string error) + { + var metadata = new Dictionary { [FeatureFlagMetadataKeys.ObserveFullEvaluationData] = "true" }; + var result = FeatureFlagsSdk.GetResolutionDetails("flag", false, new TestEvaluation(metadata, error), 1234); + + result.Value.Should().BeFalse(); + result.ErrorType.Should().NotBe(ErrorType.None); + result.FlagMetadata!.GetBool(FeatureFlagMetadataKeys.ObserveFullEvaluationData).Should().BeTrue(); + result.FlagMetadata.GetDouble("__dd_eval_timestamp_ms").Should().Be(1234); + } + + private sealed class TestEvaluation(IDictionary? metadata, string? error = null) : IEvaluation + { + public string FlagKey => "flag"; + + public object Value => true; + + public EvaluationReason Reason => error is null ? EvaluationReason.Static : EvaluationReason.Error; + + public string? Variant => "on"; + + public string? Error => error; + + public IDictionary? FlagMetadata => metadata; + } +} diff --git a/tracer/test/Datadog.FeatureFlags.OpenFeature.Tests/FlagEvalEVPHookTests.cs b/tracer/test/Datadog.FeatureFlags.OpenFeature.Tests/FlagEvalEVPHookTests.cs new file mode 100644 index 000000000000..731f2fcf02db --- /dev/null +++ b/tracer/test/Datadog.FeatureFlags.OpenFeature.Tests/FlagEvalEVPHookTests.cs @@ -0,0 +1,323 @@ +// +// 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 System.Linq; +using System.Threading.Tasks; +using Datadog.Trace.FeatureFlags; +using Datadog.Trace.FeatureFlags.FlagEvaluation; +using FluentAssertions; +using OpenFeature.Constant; +using OpenFeature.Model; +using Xunit; + +namespace Datadog.FeatureFlags.OpenFeature.Tests; + +public class FlagEvalEVPHookTests +{ + [Fact] + public async Task CapacityRejectionPrecedesSnapshot() + { + var fixture = new Fixture { Available = false, ThrowOnSnapshot = true }; + + await fixture.Hook.FinallyAsync(Context(), Details(true)); + + fixture.Captures.Should().Be(0); + fixture.Enqueues.Should().Be(0); + fixture.Errors.Should().Be(0); + } + + [Theory] + [InlineData(null)] + [InlineData(false)] + [InlineData("true")] + [InlineData(1)] + public async Task OnlyBooleanTrueCapturesContext(object? consent) + { + var fixture = new Fixture { ThrowOnSnapshot = true }; + + await fixture.Hook.FinallyAsync(Context(), Details(consent)); + + fixture.Captures.Should().Be(0); + fixture.Enqueues.Should().Be(1); + fixture.Consent.Should().BeFalse(); + fixture.Attributes.Should().BeNull(); + fixture.Errors.Should().Be(0); + } + + [Fact] + public async Task FullConsentCapturesContextAndPreservesEvaluationMetadata() + { + var fixture = new Fixture(); + + await fixture.Hook.FinallyAsync(Context(), Details(true, timestamp: 1234d)); + + fixture.Captures.Should().Be(1); + fixture.Enqueues.Should().Be(1); + fixture.Consent.Should().BeTrue(); + fixture.Attributes.Should().BeEquivalentTo(new Dictionary { ["country"] = "US" }); + fixture.TargetingKey.Should().Be("subject"); + fixture.FlagKey.Should().Be("flag"); + fixture.AllocationKey.Should().Be("allocation"); + fixture.EvalTimeMs.Should().Be(1234); + fixture.OmissionReasons.Should().Be(0); + } + + [Fact] + public async Task SnapshotFailurePreservesObservationWithoutAttributes() + { + var fixture = new Fixture { ThrowOnSnapshot = true }; + + await fixture.Hook.FinallyAsync(Context(), Details(true)); + + fixture.Enqueues.Should().Be(1); + fixture.Attributes.Should().BeNull(); + fixture.OmissionReasons.Should().Be((int)ContextOmissionReason.SnapshotError); + fixture.Errors.Should().Be(0, "snapshot omissions are counted by enqueue, not twice as hook errors"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("on")] + public async Task VariantPresenceIsPreserved(string? variant) + { + var fixture = new Fixture(); + + await fixture.Hook.FinallyAsync(Context(), Details(false, variant: variant)); + + fixture.Variant.Should().Be(variant); + fixture.Enqueues.Should().Be(1); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task MissingAndEmptyTargetingKeysRemainDistinct(string? targetingKey) + { + var fixture = new Fixture(); + var builder = EvaluationContext.Builder(); + if (targetingKey is not null) + { + builder.Set("targetingKey", targetingKey); + } + + var context = new HookContext("flag", false, FlagValueType.Boolean, new ClientMetadata("client", "1"), new Metadata("provider"), builder.Build()); + + await fixture.Hook.FinallyAsync(context, Details(false)); + + fixture.TargetingKey.Should().Be(targetingKey); + fixture.Enqueues.Should().Be(1); + } + + [Fact] + public async Task SnapshotOmissionReasonsReachEnqueue() + { + var fixture = new Fixture(); + var context = new HookContext( + "flag", + false, + FlagValueType.Boolean, + new ClientMetadata("client", "1"), + new Metadata("provider"), + EvaluationContext.Builder().Set("oversized", new string('v', 257)).Build()); + + await fixture.Hook.FinallyAsync(context, Details(true)); + + fixture.Enqueues.Should().Be(1); + fixture.Attributes.Should().BeEmpty(); + fixture.OmissionReasons.Should().Be((int)ContextOmissionReason.MaxValueLength); + fixture.Errors.Should().Be(0); + } + + [Theory] + [InlineData(ErrorType.None, null)] + [InlineData(ErrorType.FlagNotFound, "FLAG_NOT_FOUND")] + [InlineData(ErrorType.InvalidContext, "INVALID_CONTEXT")] + [InlineData(ErrorType.ParseError, "PARSE_ERROR")] + [InlineData(ErrorType.ProviderFatal, "PROVIDER_FATAL")] + [InlineData(ErrorType.ProviderNotReady, "PROVIDER_NOT_READY")] + [InlineData(ErrorType.TargetingKeyMissing, "TARGETING_KEY_MISSING")] + [InlineData(ErrorType.TypeMismatch, "TYPE_MISMATCH")] + [InlineData(ErrorType.General, "GENERAL")] + [InlineData((ErrorType)999, "GENERAL")] + public async Task ErrorUsesStableCodeNotMessage(ErrorType error, string? expected) + { + var fixture = new Fixture(); + + await fixture.Hook.FinallyAsync(Context(), Details(true, error: error)); + + fixture.Enqueues.Should().Be(1); + fixture.ErrorCode.Should().Be(expected); + } + + [Theory] + [InlineData(null)] + [InlineData("1234")] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(-1d)] + [InlineData(1234.5d)] + [InlineData(double.MaxValue)] + public async Task InvalidTimestampFallsBackToHookTime(object? timestamp) + { + var fixture = new Fixture(); + var before = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + await fixture.Hook.FinallyAsync(Context(), Details(false, timestamp: timestamp)); + + fixture.EvalTimeMs.Should().BeInRange(before, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + fixture.Enqueues.Should().Be(1); + } + + [Fact] + public async Task MissingMetadataIsProtected() + { + var fixture = new Fixture { ThrowOnSnapshot = true }; + var details = new FlagEvaluationDetails("flag", false, ErrorType.ProviderNotReady, null, null); + + await fixture.Hook.FinallyAsync(Context(), details); + + fixture.Enqueues.Should().Be(1); + fixture.Consent.Should().BeFalse(); + fixture.Captures.Should().Be(0); + fixture.AllocationKey.Should().BeNull(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task HookAndMetricSinkFailuresCannotEscape(bool failCapacity) + { + var fixture = new Fixture { ThrowOnCapacity = failCapacity, ThrowOnEnqueue = !failCapacity, ThrowOnError = true }; + + await fixture.Hook.FinallyAsync(Context(), Details(true)); + + fixture.Errors.Should().Be(1); + } + + [Fact] + public async Task UninstrumentedProviderRegistersInertHook() + { + using var provider = new DatadogProvider(); + var hook = provider.GetProviderHooks().OfType().Single(); + + FeatureFlagsSdk.CanEnqueueEVP().Should().BeFalse(); + await hook.FinallyAsync(Context(), Details(true)); + var result = await provider.ResolveBooleanValueAsync("flag", false); + result.Value.Should().BeFalse(); + result.ErrorType.Should().Be(ErrorType.ProviderNotReady); + } + + private static HookContext Context() => new( + "flag", + false, + FlagValueType.Boolean, + new ClientMetadata("client", "1"), + new Metadata("provider"), + EvaluationContext.Builder().Set("targetingKey", "subject").Set("country", "US").Build()); + + private static FlagEvaluationDetails Details(object? consent, string? variant = "on", ErrorType error = ErrorType.None, object? timestamp = null) + { + var metadata = new Dictionary { ["__dd_allocation_key"] = "allocation" }; + if (consent is not null) + { + metadata[FeatureFlagMetadataKeys.ObserveFullEvaluationData] = consent; + } + + if (timestamp is not null) + { + metadata[FeatureFlagMetadataKeys.EvaluationTimestampMs] = timestamp; + } + + return new FlagEvaluationDetails("flag", true, error, "static", variant, "pii-error-canary", new ImmutableMetadata(metadata)); + } + + private sealed class Fixture + { + public Fixture() + { + Hook = new FlagEvalEVPHook( + () => ThrowOnCapacity ? throw new InvalidOperationException("pii-canary") : Available, + (flag, variant, allocation, target, time, consent, error, attrs, reasons) => + { + if (ThrowOnEnqueue) + { + throw new InvalidOperationException("pii-canary"); + } + + Enqueues++; + FlagKey = flag; + Variant = variant; + AllocationKey = allocation; + TargetingKey = target; + EvalTimeMs = time; + Consent = consent; + ErrorCode = error; + Attributes = attrs; + OmissionReasons = reasons; + }, + () => + { + Errors++; + if (ThrowOnError) + { + throw new InvalidOperationException("pii-canary"); + } + }, + Capture); + } + + public FlagEvalEVPHook Hook { get; } + + public bool Available { get; set; } = true; + + public bool ThrowOnSnapshot { get; set; } + + public bool ThrowOnCapacity { get; set; } + + public bool ThrowOnEnqueue { get; set; } + + public bool ThrowOnError { get; set; } + + public int Captures { get; private set; } + + public int Enqueues { get; private set; } + + public int Errors { get; private set; } + + public string? FlagKey { get; private set; } + + public string? Variant { get; private set; } + + public string? AllocationKey { get; private set; } + + public string? TargetingKey { get; private set; } + + public long EvalTimeMs { get; private set; } + + public bool Consent { get; private set; } + + public string? ErrorCode { get; private set; } + + public IReadOnlyDictionary? Attributes { get; private set; } + + public int OmissionReasons { get; private set; } + + private IReadOnlyDictionary Capture(EvaluationContext? context, out int reasons) + { + Captures++; + if (ThrowOnSnapshot) + { + throw new InvalidOperationException("pii-canary"); + } + + return FlagEvaluationContextSnapshot.Capture(context, out reasons); + } + } +} diff --git a/tracer/test/Datadog.FeatureFlags.OpenFeature.Tests/FlagEvaluationContextSnapshotTests.cs b/tracer/test/Datadog.FeatureFlags.OpenFeature.Tests/FlagEvaluationContextSnapshotTests.cs new file mode 100644 index 000000000000..0e5b8c2e9ff0 --- /dev/null +++ b/tracer/test/Datadog.FeatureFlags.OpenFeature.Tests/FlagEvaluationContextSnapshotTests.cs @@ -0,0 +1,168 @@ +// +// 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 System.Linq; +using Datadog.Trace.FeatureFlags.FlagEvaluation; +using FluentAssertions; +using OpenFeature.Model; +using Xunit; + +namespace Datadog.FeatureFlags.OpenFeature.Tests; + +public class FlagEvaluationContextSnapshotTests +{ + [Fact] + public void CapturesOnlyFlatScalarsAndOmitsRootTargetingKey() + { + var context = EvaluationContext.Builder() + .Set("targetingKey", "subject") + .Set("user", new Value(new Structure(new Dictionary + { + ["active"] = new(true), + ["age"] = new(42), + ["country"] = new("US"), + ["targetingKey"] = new("nested"), + ["tags"] = new(new List { new("paid"), new(false) }), + }))) + .Build(); + + var snapshot = FlagEvaluationContextSnapshot.Capture(context, out var reasons); + + snapshot.Should().BeEquivalentTo(new Dictionary + { + ["user.active"] = true, + ["user.age"] = 42d, + ["user.country"] = "US", + ["user.targetingKey"] = "nested", + ["user.tags.0"] = "paid", + ["user.tags.1"] = false, + }); + reasons.Should().Be(0); + } + + [Fact] + public void OversizedMapsUseRepeatableBoundedSelection() + { + var values = Enumerable.Range(0, 300).ToDictionary(i => "field" + i, i => new Value(i)); + var firstContext = EvaluationContext.Builder().Set("data", new Value(new Structure(values))).Build(); + var rebuiltContext = EvaluationContext.Builder().Set("data", new Value(new Structure(values.Reverse().ToDictionary(p => p.Key, p => p.Value)))).Build(); + + var first = FlagEvaluationContextSnapshot.Capture(firstContext, out var reasons); + + first.Should().HaveCount(256); + reasons.Should().NotBe(0); + FlagEvaluationContextSnapshot.Capture(firstContext, out _).Should().BeEquivalentTo(first); + FlagEvaluationContextSnapshot.Capture(rebuiltContext, out _).Should().BeEquivalentTo(first); + } + + [Fact] + public void SnapshotDoesNotRetainMutableInputs() + { + var list = new List { new("before") }; + var values = new Dictionary { ["items"] = new(list) }; + var builder = EvaluationContext.Builder().Set("data", new Value(new Structure(values))); + var context = builder.Build(); + var snapshot = FlagEvaluationContextSnapshot.Capture(context, out _); + + list[0] = new("after"); + values.Clear(); + builder.Set("data", "after"); + + snapshot.Should().BeEquivalentTo(new Dictionary { ["data.items.0"] = "before" }); + } + + [Fact] + public void OmitsUnsupportedAndOversizedValuesWithoutTruncation() + { + var context = EvaluationContext.Builder() + .Set(new string('k', 257), "oversized key") + .Set("oversized", new string('v', 257)) + .Set(new string('k', 256), new string('v', 256)) + .Set("date", new Value(DateTime.UtcNow)) + .Set("null", new Value()) + .Set("nan", double.NaN) + .Set("positiveInfinity", double.PositiveInfinity) + .Set("negativeInfinity", double.NegativeInfinity) + .Build(); + + var snapshot = FlagEvaluationContextSnapshot.Capture(context, out var reasons); + + snapshot.Should().BeEquivalentTo(new Dictionary { [new string('k', 256)] = new string('v', 256) }); + reasons.Should().Be((int)(ContextOmissionReason.MaxKeyLength | ContextOmissionReason.MaxValueLength | ContextOmissionReason.UnsupportedValue)); + } + + [Fact] + public void FlattenedKeyLengthIsBounded() + { + var context = EvaluationContext.Builder().Set("parent", new Value(new Structure(new Dictionary { [new string('k', 256)] = new(true) }))).Build(); + + FlagEvaluationContextSnapshot.Capture(context, out var reasons).Should().BeEmpty(); + reasons.Should().Be((int)ContextOmissionReason.MaxKeyLength); + } + + [Fact] + public void ListTraversalStopsEvenWhenEveryValueIsDiscarded() + { + var list = Enumerable.Repeat(new Value(double.NaN), 256).Concat(new[] { new Value("must not visit") }).ToList(); + var context = EvaluationContext.Builder().Set("list", new Value(list)).Build(); + + FlagEvaluationContextSnapshot.Capture(context, out var reasons).Should().BeEmpty(); + reasons.Should().Be((int)(ContextOmissionReason.MaxListElements | ContextOmissionReason.UnsupportedValue)); + } + + [Fact] + public void RetainedFieldLimitAppliesAcrossContainers() + { + var list = Enumerable.Range(0, 200).Select(i => new Value(i)).ToList(); + var context = EvaluationContext.Builder().Set("a", new Value(list)).Set("b", new Value(list)).Build(); + + FlagEvaluationContextSnapshot.Capture(context, out var reasons).Should().HaveCount(256); + ((ContextOmissionReason)reasons).Should().HaveFlag(ContextOmissionReason.MaxContextFields); + } + + [Theory] + [InlineData(4, true)] + [InlineData(5, false)] + public void DepthIsBounded(int depth, bool retained) + { + var value = new Value("leaf"); + for (var i = 1; i < depth; i++) + { + value = new Value(new Structure(new Dictionary { ["child"] = value })); + } + + var context = EvaluationContext.Builder().Set("root", value).Build(); + var snapshot = FlagEvaluationContextSnapshot.Capture(context, out var reasons); + + snapshot.Should().HaveCount(retained ? 1 : 0); + reasons.Should().Be(retained ? 0 : (int)ContextOmissionReason.MaxSnapshotDepth); + } + + [Fact] + public void TotalVisitsAreBoundedEvenWhenNothingIsRetained() + { + var value = new Value(double.NaN); + for (var i = 0; i < 3; i++) + { + value = new Value(Enumerable.Repeat(value, 256).ToList()); + } + + var context = EvaluationContext.Builder().Set("root", value).Build(); + + FlagEvaluationContextSnapshot.Capture(context, out var reasons).Should().BeEmpty(); + ((ContextOmissionReason)reasons).Should().HaveFlag(ContextOmissionReason.MaxVisitedNodes); + } + + [Fact] + public void MissingContextIsEmpty() + { + FlagEvaluationContextSnapshot.Capture(null, out var reasons).Should().BeEmpty(); + reasons.Should().Be(0); + } +} From 0712aaec63827b8da060bfa929769afa271c8495 Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Mon, 28 Sep 2026 19:53:21 +0000 Subject: [PATCH 03/15] feat(ffe): aggregate and serialize privacy-safe flag evaluations --- .../FlagEvaluation/DegradedKey.cs | 11 + .../FlagEvaluation/DrainResult.cs | 25 ++ .../FlagEvaluation/EvaluationEntry.cs | 52 +++ .../FlagEvaluation/FlagEvalEvent.cs | 48 +++ .../FlagEvaluationAggregator.cs | 87 +++++ .../FlagEvaluation/FlagEvaluationContext.cs | 127 ++++++++ .../FlagEvaluation/FlagEvaluationPayload.cs | 168 ++++++++++ .../FlagEvaluationPayloadResult.cs | 21 ++ .../FlagEvaluation/FlagEvaluationPrivacy.cs | 47 +++ .../FeatureFlags/FlagEvaluation/FullKey.cs | 10 + .../FlagEvaluationAggregatorTests.cs | 258 +++++++++++++++ .../FlagEvaluationContextTests.cs | 102 ++++++ .../FlagEvaluationPayloadTests.cs | 300 ++++++++++++++++++ .../FlagEvaluationPrivacyTests.cs | 84 +++++ 14 files changed, 1340 insertions(+) create mode 100644 tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/DegradedKey.cs create mode 100644 tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/DrainResult.cs create mode 100644 tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/EvaluationEntry.cs create mode 100644 tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvalEvent.cs create mode 100644 tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAggregator.cs create mode 100644 tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationContext.cs create mode 100644 tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationPayload.cs create mode 100644 tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationPayloadResult.cs create mode 100644 tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationPrivacy.cs create mode 100644 tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FullKey.cs create mode 100644 tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationAggregatorTests.cs create mode 100644 tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationContextTests.cs create mode 100644 tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationPayloadTests.cs create mode 100644 tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationPrivacyTests.cs diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/DegradedKey.cs b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/DegradedKey.cs new file mode 100644 index 000000000000..42de456cf971 --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/DegradedKey.cs @@ -0,0 +1,11 @@ +// +// 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 + +namespace Datadog.Trace.FeatureFlags.FlagEvaluation; + +// Consent is absent: degraded output omits both subject identity and evaluation context. +internal readonly record struct DegradedKey(string FlagKey, string? Variant, string? AllocationKey, string? ErrorCode); diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/DrainResult.cs b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/DrainResult.cs new file mode 100644 index 000000000000..3cf694a83762 --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/DrainResult.cs @@ -0,0 +1,25 @@ +// +// 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.Collections.Generic; + +namespace Datadog.Trace.FeatureFlags.FlagEvaluation; + +internal sealed class DrainResult( + Dictionary full, + Dictionary degraded, + long dropped, + long invalidTargetingKeys = 0) +{ + public Dictionary Full { get; } = full; + + public Dictionary Degraded { get; } = degraded; + + public long Dropped { get; } = dropped; + + public long InvalidTargetingKeys { get; } = invalidTargetingKeys; +} diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/EvaluationEntry.cs b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/EvaluationEntry.cs new file mode 100644 index 000000000000..8eb281daba9e --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/EvaluationEntry.cs @@ -0,0 +1,52 @@ +// +// 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.Collections.Generic; + +namespace Datadog.Trace.FeatureFlags.FlagEvaluation; + +internal sealed class EvaluationEntry +{ + public EvaluationEntry(long evalTimeMs, bool observeFullEvaluationData, IReadOnlyDictionary? contextAttrs) + { + Count = 1; + FirstEvaluationMs = evalTimeMs; + LastEvaluationMs = evalTimeMs; + ObserveFullEvaluationData = observeFullEvaluationData; + ContextAttrs = observeFullEvaluationData ? contextAttrs : null; + } + + public long Count { get; private set; } + + public long FirstEvaluationMs { get; private set; } + + public long LastEvaluationMs { get; private set; } + + public bool ObserveFullEvaluationData { get; private set; } + + public IReadOnlyDictionary? ContextAttrs { get; private set; } + + public void Observe(long evalTimeMs, bool observeFullEvaluationData) + { + Count++; + ObserveFullEvaluationData &= observeFullEvaluationData; + if (!ObserveFullEvaluationData) + { + ContextAttrs = null; + } + + if (evalTimeMs < FirstEvaluationMs) + { + FirstEvaluationMs = evalTimeMs; + } + + if (evalTimeMs > LastEvaluationMs) + { + LastEvaluationMs = evalTimeMs; + } + } +} diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvalEvent.cs b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvalEvent.cs new file mode 100644 index 000000000000..e4af6b4ed144 --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvalEvent.cs @@ -0,0 +1,48 @@ +// +// 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.Collections.Generic; + +namespace Datadog.Trace.FeatureFlags.FlagEvaluation; + +internal sealed class FlagEvalEvent +{ + public FlagEvalEvent(string flagKey, string? variant, string? allocationKey, string? targetingKey, long evalTimeMs, IReadOnlyDictionary? contextAttrs, string? errorCode = null, bool observeFullEvaluationData = false) + { + FlagKey = flagKey; + Variant = variant; + AllocationKey = allocationKey; + TargetingKey = targetingKey; + EvalTimeMs = evalTimeMs; + if (observeFullEvaluationData) + { + ContextAttrs = FlagEvaluationContext.Copy(contextAttrs, out var omissions); + ContextOmissions = omissions; + } + + ErrorCode = errorCode; + ObserveFullEvaluationData = observeFullEvaluationData; + } + + public string FlagKey { get; } + + public string? Variant { get; } + + public string? AllocationKey { get; } + + public string? TargetingKey { get; } + + public long EvalTimeMs { get; } + + public IReadOnlyDictionary? ContextAttrs { get; } + + public string? ErrorCode { get; } + + public bool ObserveFullEvaluationData { get; } + + public ContextOmissionReason ContextOmissions { get; } +} diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAggregator.cs b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAggregator.cs new file mode 100644 index 000000000000..355e635071a1 --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAggregator.cs @@ -0,0 +1,87 @@ +// +// 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; + +namespace Datadog.Trace.FeatureFlags.FlagEvaluation; + +// The background worker is the sole owner of Add and Drain. +internal sealed class FlagEvaluationAggregator +{ + private readonly int _globalCap; + private readonly int _perFlagCap; + private readonly int _degradedCap; + private readonly Dictionary _perFlag = new(StringComparer.Ordinal); + private Dictionary _full = []; + private Dictionary _degraded = []; + private long _dropped; + private long _invalidTargetingKeys; + + public FlagEvaluationAggregator(int globalCap, int perFlagCap, int degradedCap) + { + _globalCap = Math.Max(0, globalCap); + _perFlagCap = Math.Max(0, perFlagCap); + _degradedCap = Math.Max(0, degradedCap); + } + + public void Add(FlagEvalEvent observation) + { + var targetingKey = observation.TargetingKey; + if (targetingKey is not null && !FlagEvaluationPrivacy.IsValidText(targetingKey)) + { + targetingKey = null; + _invalidTargetingKeys++; + } + + // Revalidate independently of the provider and event constructor. Protected observations + // never enumerate attributes, and canonicalization only sorts already-bounded data. + var consent = observation.ObserveFullEvaluationData; + var attrs = consent ? FlagEvaluationContext.Copy(observation.ContextAttrs, out _) : null; + var contextKey = FlagEvaluationContext.CanonicalKey(attrs); + var dimensions = new DegradedKey(observation.FlagKey, observation.Variant, observation.AllocationKey, FlagEvaluationPrivacy.ErrorCodeForOutput(observation.ErrorCode)); + var fullKey = new FullKey(dimensions, targetingKey, contextKey, consent); + if (_full.TryGetValue(fullKey, out var existing)) + { + existing.Observe(observation.EvalTimeMs, consent); + return; + } + + _perFlag.TryGetValue(observation.FlagKey, out var perFlagCount); + if (_full.Count < _globalCap && perFlagCount < _perFlagCap) + { + _full.Add(fullKey, new EvaluationEntry(observation.EvalTimeMs, consent, attrs)); + // Only track flags with retained full buckets, so this map is bounded too. + _perFlag[observation.FlagKey] = perFlagCount + 1; + return; + } + + if (_degraded.TryGetValue(dimensions, out existing)) + { + existing.Observe(observation.EvalTimeMs, consent); + } + else if (_degraded.Count < _degradedCap) + { + _degraded.Add(dimensions, new EvaluationEntry(observation.EvalTimeMs, consent, null)); + } + else + { + _dropped++; + } + } + + public DrainResult Drain() + { + var result = new DrainResult(_full, _degraded, _dropped, _invalidTargetingKeys); + _full = []; + _degraded = []; + _perFlag.Clear(); + _dropped = 0; + _invalidTargetingKeys = 0; + return result; + } +} diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationContext.cs b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationContext.cs new file mode 100644 index 000000000000..485515f85d88 --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationContext.cs @@ -0,0 +1,127 @@ +// +// 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 System.Globalization; +using System.Text; + +namespace Datadog.Trace.FeatureFlags.FlagEvaluation; + +internal static class FlagEvaluationContext +{ + private const int MaxFields = 256; + private const int MaxTextLength = 256; + + internal static Dictionary? Copy(IReadOnlyDictionary? attributes, out ContextOmissionReason omissions) + { + omissions = ContextOmissionReason.None; + if (attributes is null) + { + return null; + } + + try + { + var count = attributes.Count; + if (count == 0) + { + return null; + } + + if (count > MaxFields) + { + omissions |= ContextOmissionReason.MaxContextFields; + } + + var result = new Dictionary(Math.Min(count, MaxFields), StringComparer.Ordinal); + using var iterator = attributes.GetEnumerator(); + for (var i = 0; i < MaxFields && iterator.MoveNext(); i++) + { + var pair = iterator.Current; + if (pair.Key is null) + { + omissions |= ContextOmissionReason.UnsupportedValue; + continue; + } + + if (pair.Key.Length > MaxTextLength) + { + omissions |= ContextOmissionReason.MaxKeyLength; + continue; + } + + if (!FlagEvaluationPrivacy.IsValidText(pair.Key)) + { + omissions |= ContextOmissionReason.UnsupportedValue; + continue; + } + + if (pair.Value is string text && text.Length > MaxTextLength) + { + omissions |= ContextOmissionReason.MaxValueLength; + continue; + } + + if (!IsScalar(pair.Value)) + { + omissions |= ContextOmissionReason.UnsupportedValue; + continue; + } + + result[pair.Key] = pair.Value; + } + + return result.Count == 0 ? null : result; + } + catch (Exception) + { + omissions = ContextOmissionReason.SnapshotError; + return null; + } + } + + internal static string CanonicalKey(Dictionary? attributes) + { + if (attributes is null || attributes.Count == 0) + { + return string.Empty; + } + + var keys = new List(attributes.Keys); + keys.Sort(StringComparer.Ordinal); + var builder = new StringBuilder(); + foreach (var key in keys) + { + var value = attributes[key]!; + // Copy admits only primitives. Type tags keep 1, 1d, true and "1" distinct; + // character-length prefixes prevent keys or values from forging field boundaries. + builder.Append(key.Length.ToString(CultureInfo.InvariantCulture)).Append(':').Append(key); + builder.Append(((int)Type.GetTypeCode(value.GetType())).ToString(CultureInfo.InvariantCulture)).Append(':'); + var text = value switch + { + string s => s, + bool b => b ? "true" : "false", + double d => d.ToString("R", CultureInfo.InvariantCulture), + float f => f.ToString("R", CultureInfo.InvariantCulture), + _ => Convert.ToString(value, CultureInfo.InvariantCulture)!, + }; + builder.Append(text.Length.ToString(CultureInfo.InvariantCulture)).Append(':').Append(text); + } + + return builder.ToString(); + } + + private static bool IsScalar(object? value) => value switch + { + string text => FlagEvaluationPrivacy.IsValidText(text), + bool or byte or sbyte or short or ushort or int or uint or long or ulong or decimal => true, + double number => !double.IsNaN(number) && !double.IsInfinity(number), + float number => !float.IsNaN(number) && !float.IsInfinity(number), + _ => false, + }; +} diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationPayload.cs b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationPayload.cs new file mode 100644 index 000000000000..440b182f5b57 --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationPayload.cs @@ -0,0 +1,168 @@ +// +// 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 System.IO; +using System.Text; +using Datadog.Trace.Util.Json; + +namespace Datadog.Trace.FeatureFlags.FlagEvaluation; + +internal static class FlagEvaluationPayload +{ + internal static FlagEvaluationPayloadResult Encode(DrainResult state, IReadOnlyDictionary serviceContext, long flushTimeMs, int payloadLimitBytes) + { + var context = new Dictionary(); + foreach (var name in new[] { "service", "env", "version" }) + { + if (serviceContext.TryGetValue(name, out var value) && value is not null) + { + context[name] = value; + } + } + + var prefix = Encoding.UTF8.GetBytes("{\"context\":" + JsonHelper.SerializeObject(context) + ",\"flagEvaluations\":["); + var payloads = new List(); + long payloadDegraded = 0; + long payloadDropped = 0; + long serializationDropped = 0; + using var batch = new MemoryStream(); + var rows = 0; + foreach (var pair in state.Full) + { + Append(pair.Key.Dimensions, pair.Value, pair.Key.TargetingKey, pair.Key.ObserveFullEvaluationData, degraded: false); + } + + foreach (var pair in state.Degraded) + { + Append(pair.Key, pair.Value, null, false, degraded: true); + } + + FinishBatch(); + return new FlagEvaluationPayloadResult(payloads, payloadDegraded, payloadDropped, serializationDropped); + + void Append(DegradedKey dimensions, EvaluationEntry entry, string? targetingKey, bool consent, bool degraded) + { + byte[] encoded; + try + { + encoded = EncodeRow(dimensions, entry, targetingKey, consent, degraded, flushTimeMs); + // Measure a row against an empty batch before adding it. Degradation is only + // needed when the row cannot fit by itself, not when the current batch is full. + if ((long)prefix.Length + encoded.Length + 2 > payloadLimitBytes) + { + if (!degraded) + { + encoded = EncodeRow(dimensions, entry, null, false, degraded: true, flushTimeMs); + } + + if ((long)prefix.Length + encoded.Length + 2 > payloadLimitBytes) + { + payloadDropped += entry.Count; + return; + } + + payloadDegraded += entry.Count; + } + } + catch (Exception) + { + // Never include exception text: it may contain customer input. + serializationDropped += entry.Count; + return; + } + + if (rows > 0 && batch.Length + 1 + encoded.Length + 2 > payloadLimitBytes) + { + FinishBatch(); + } + + if (rows == 0) + { + batch.Write(prefix, 0, prefix.Length); + } + else + { + batch.WriteByte((byte)','); + } + + batch.Write(encoded, 0, encoded.Length); + rows++; + } + + void FinishBatch() + { + if (rows == 0) + { + return; + } + + batch.WriteByte((byte)']'); + batch.WriteByte((byte)'}'); + payloads.Add(batch.ToArray()); + batch.SetLength(0); + batch.Position = 0; + rows = 0; + } + } + + private static byte[] EncodeRow(DegradedKey dimensions, EvaluationEntry entry, string? targetingKey, bool consent, bool degraded, long flushTimeMs) + { + if (dimensions.FlagKey is null || entry.Count < 1) + { + throw new InvalidOperationException("Invalid flag evaluation entry"); + } + + var row = new Dictionary + { + ["timestamp"] = flushTimeMs, + ["flag"] = Key(dimensions.FlagKey), + ["first_evaluation"] = entry.FirstEvaluationMs, + ["last_evaluation"] = entry.LastEvaluationMs, + ["evaluation_count"] = entry.Count, + }; + if (dimensions.Variant is null) + { + row["runtime_default_used"] = true; + } + else + { + row["variant"] = Key(dimensions.Variant); + } + + if (dimensions.AllocationKey is not null) + { + row["allocation"] = Key(dimensions.AllocationKey); + } + + if (FlagEvaluationPrivacy.ErrorCodeForOutput(dimensions.ErrorCode) is { } errorCode) + { + row["error"] = new Dictionary { ["message"] = errorCode }; + } + + if (!degraded) + { + // Serialization remains a privacy boundary even if an upstream caller bypasses + // normalization. Either consent check can make the entry protected. + consent &= entry.ObserveFullEvaluationData; + if (FlagEvaluationPrivacy.TargetingKeyForOutput(targetingKey, consent) is { } outputKey) + { + row["targeting_key"] = outputKey; + } + + if (consent && FlagEvaluationContext.Copy(entry.ContextAttrs, out _) is { } attributes) + { + row["context"] = new Dictionary { ["evaluation"] = attributes }; + } + } + + return Encoding.UTF8.GetBytes(JsonHelper.SerializeObject(row)); + } + + private static Dictionary Key(string value) => new() { ["key"] = value }; +} diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationPayloadResult.cs b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationPayloadResult.cs new file mode 100644 index 000000000000..efeda9da7caf --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationPayloadResult.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. +// + +#nullable enable + +using System.Collections.Generic; + +namespace Datadog.Trace.FeatureFlags.FlagEvaluation; + +internal sealed class FlagEvaluationPayloadResult(IReadOnlyList payloads, long payloadDegradedEvaluations, long payloadDroppedEvaluations, long serializationDroppedEvaluations) +{ + public IReadOnlyList Payloads { get; } = payloads; + + public long PayloadDegradedEvaluations { get; } = payloadDegradedEvaluations; + + public long PayloadDroppedEvaluations { get; } = payloadDroppedEvaluations; + + public long SerializationDroppedEvaluations { get; } = serializationDroppedEvaluations; +} diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationPrivacy.cs b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationPrivacy.cs new file mode 100644 index 000000000000..8c93e4e151e5 --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationPrivacy.cs @@ -0,0 +1,47 @@ +// +// 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.Text; +using Datadog.Trace.Util; + +namespace Datadog.Trace.FeatureFlags.FlagEvaluation; + +internal static class FlagEvaluationPrivacy +{ + private static readonly Encoding StrictUtf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + internal static string? TargetingKeyForOutput(string? key, bool consent) + { + if (key is null || !IsValidText(key)) + { + return null; + } + + return consent || key.Length == 0 ? key : "sha256_" + Sha256Helper.ComputeHashAsHexString(key); + } + + internal static string? ErrorCodeForOutput(string? code) => code switch + { + null or "" => null, + "FLAG_NOT_FOUND" or "INVALID_CONTEXT" or "PARSE_ERROR" or "PROVIDER_FATAL" or + "PROVIDER_NOT_READY" or "TARGETING_KEY_MISSING" or "TYPE_MISMATCH" or "GENERAL" => code, + _ => "GENERAL", + }; + + internal static bool IsValidText(string text) + { + try + { + StrictUtf8.GetByteCount(text); + return true; + } + catch (EncoderFallbackException) + { + return false; + } + } +} diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FullKey.cs b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FullKey.cs new file mode 100644 index 000000000000..43a4cae168e2 --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FullKey.cs @@ -0,0 +1,10 @@ +// +// 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 + +namespace Datadog.Trace.FeatureFlags.FlagEvaluation; + +internal readonly record struct FullKey(DegradedKey Dimensions, string? TargetingKey, string ContextKey, bool ObserveFullEvaluationData); diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationAggregatorTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationAggregatorTests.cs new file mode 100644 index 000000000000..0fc20da41570 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationAggregatorTests.cs @@ -0,0 +1,258 @@ +// +// 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; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Datadog.Trace.FeatureFlags.FlagEvaluation; +using FluentAssertions; +using Xunit; + +namespace Datadog.Trace.Tests.FeatureFlags; + +public class FlagEvaluationAggregatorTests +{ + [Theory] + [InlineData(false, false, 1)] + [InlineData(false, true, 2)] + [InlineData(true, false, 2)] + [InlineData(true, true, 1)] + public void FullIdentityIncludesConsent(bool first, bool second, int buckets) + { + var aggregator = new FlagEvaluationAggregator(10, 10, 10); + aggregator.Add(Observation(consent: first)); + aggregator.Add(Observation(consent: second)); + + var drained = aggregator.Drain(); + + drained.Full.Should().HaveCount(buckets); + drained.Full.Values.Sum(e => e.Count).Should().Be(2); + drained.Degraded.Should().BeEmpty(); + drained.Dropped.Should().Be(0); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void DegradedIdentityExcludesConsentAndSubject(bool first, bool second) + { + var aggregator = new FlagEvaluationAggregator(0, 0, 10); + aggregator.Add(Observation(consent: first, target: "first", attrs: new Dictionary { ["country"] = "US" })); + aggregator.Add(Observation(consent: second, target: "second", attrs: new Dictionary { ["country"] = "CA" })); + + var drained = aggregator.Drain(); + var entry = drained.Degraded.Should().ContainSingle().Subject.Value; + + drained.Full.Should().BeEmpty(); + entry.Count.Should().Be(2); + entry.ObserveFullEvaluationData.Should().Be(first && second); + entry.ContextAttrs.Should().BeNull(); + } + + [Fact] + public void ProtectedEventsNeverInspectCallerAttributes() + { + var observation = Observation(attrs: new ThrowingAttributes()); + var aggregator = new FlagEvaluationAggregator(10, 10, 10); + + observation.ContextAttrs.Should().BeNull(); + aggregator.Add(observation); + aggregator.Add(Observation(attrs: new Dictionary { ["pii"] = "different" })); + + var entry = aggregator.Drain().Full.Should().ContainSingle().Subject.Value; + entry.Count.Should().Be(2); + entry.ContextAttrs.Should().BeNull(); + } + + [Fact] + public void EventConstructorIndependentlyDiscardsProtectedAttributes() + { + Observation(attrs: new Dictionary { ["pii"] = "canary" }).ContextAttrs.Should().BeNull(); + } + + [Fact] + public void InvalidTargetIsOmittedAndCountedOncePerAdmission() + { + var aggregator = new FlagEvaluationAggregator(10, 10, 10); + aggregator.Add(Observation(target: "\uD800")); + aggregator.Add(Observation(target: "\uDC00")); + aggregator.Add(Observation(target: null)); + aggregator.Add(Observation(target: string.Empty)); + + var drained = aggregator.Drain(); + + drained.InvalidTargetingKeys.Should().Be(2); + drained.Full.Should().HaveCount(2); + drained.Full.Single(p => p.Key.TargetingKey is null).Value.Count.Should().Be(3); + drained.Full.Single(p => p.Key.TargetingKey == string.Empty).Value.Count.Should().Be(1); + aggregator.Drain().InvalidTargetingKeys.Should().Be(0); + } + + [Fact] + public void AggregationIndependentlyDiscardsInjectedProtectedAttributes() + { + var observation = Observation(attrs: null); + // Deliberately bypass the event constructor's normalization to test the next boundary. + typeof(FlagEvalEvent).GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(observation, new Dictionary { ["pii"] = "injected-canary" }); + observation.ContextAttrs.Should().NotBeNull(); + var aggregator = new FlagEvaluationAggregator(10, 10, 10); + + aggregator.Add(observation); + aggregator.Add(Observation()); + + var bucket = aggregator.Drain().Full.Should().ContainSingle().Subject; + bucket.Key.ContextKey.Should().BeEmpty(); + bucket.Value.ContextAttrs.Should().BeNull(); + bucket.Value.Count.Should().Be(2); + } + + [Fact] + public void OwnsBoundedContextAndCanonicalKeyRetainsScalarTypes() + { + var attrs = new Dictionary { ["value"] = 1, ["country"] = "US" }; + var observation = Observation(consent: true, attrs: attrs); + attrs["value"] = "changed"; + var aggregator = new FlagEvaluationAggregator(10, 10, 10); + aggregator.Add(observation); + aggregator.Add(Observation(consent: true, attrs: new Dictionary { ["country"] = "US", ["value"] = 1 })); + aggregator.Add(Observation(consent: true, attrs: new Dictionary { ["country"] = "US", ["value"] = "1" })); + aggregator.Add(Observation(consent: true, attrs: new Dictionary { ["country"] = "US", ["value"] = 1d })); + + var drained = aggregator.Drain(); + + drained.Full.Should().HaveCount(3); + drained.Full.Values.Select(e => e.Count).Should().BeEquivalentTo(new long[] { 2, 1, 1 }); + observation.ContextAttrs!["value"].Should().Be(1); + } + + [Fact] + public void SnapshotFailurePreservesEvaluationAndDoesNotCallToString() + { + var aggregator = new FlagEvaluationAggregator(10, 10, 10); + aggregator.Add(Observation(consent: true, attrs: new ThrowingAttributes())); + aggregator.Add(Observation(consent: true, attrs: new Dictionary { ["unsupported"] = new ExplosiveValue() })); + + var entry = aggregator.Drain().Full.Should().ContainSingle().Subject.Value; + entry.Count.Should().Be(2); + entry.ContextAttrs.Should().BeNull(); + } + + [Fact] + public void ExistingBucketsStillIncrementAtCapacityAndDrainResetsState() + { + var aggregator = new FlagEvaluationAggregator(1, 1, 1); + aggregator.Add(Observation(target: "first", time: 20)); + aggregator.Add(Observation(target: "first", time: 10)); + aggregator.Add(Observation(target: "second", time: 40)); + aggregator.Add(Observation(target: "third", time: 30)); + aggregator.Add(Observation(flag: "different")); + + var drained = aggregator.Drain(); + var full = drained.Full.Should().ContainSingle().Subject.Value; + var degraded = drained.Degraded.Should().ContainSingle().Subject.Value; + + full.Count.Should().Be(2); + full.FirstEvaluationMs.Should().Be(10); + full.LastEvaluationMs.Should().Be(20); + degraded.Count.Should().Be(2); + degraded.FirstEvaluationMs.Should().Be(30); + degraded.LastEvaluationMs.Should().Be(40); + drained.Dropped.Should().Be(1); + aggregator.Add(Observation(flag: "different")); + aggregator.Drain().Full.Should().ContainSingle(); + aggregator.Drain().Full.Should().BeEmpty(); + } + + [Fact] + public void PerFlagLimitDoesNotPreventAnotherFlagsFullBucket() + { + var aggregator = new FlagEvaluationAggregator(10, 1, 10); + aggregator.Add(Observation(target: "first")); + aggregator.Add(Observation(target: "second")); + aggregator.Add(Observation(flag: "different")); + + var drained = aggregator.Drain(); + + drained.Full.Should().HaveCount(2); + drained.Degraded.Should().ContainSingle(); + } + + [Fact] + public void NullAndEmptyVariantsAreDistinctInBothTiers() + { + foreach (var cap in new[] { 0, 10 }) + { + var aggregator = new FlagEvaluationAggregator(cap, cap, 10); + aggregator.Add(Observation(variant: null)); + aggregator.Add(Observation(variant: string.Empty)); + + var drained = aggregator.Drain(); + (drained.Full.Count + drained.Degraded.Count).Should().Be(2); + } + } + + [Fact] + public void ErrorsAreSanitizedBeforeTheyBecomeAggregationDimensions() + { + var aggregator = new FlagEvaluationAggregator(10, 10, 10); + aggregator.Add(Observation(error: "private one")); + aggregator.Add(Observation(error: "private two")); + + var bucket = aggregator.Drain().Full.Should().ContainSingle().Subject; + + bucket.Key.Dimensions.ErrorCode.Should().Be("GENERAL"); + bucket.Value.Count.Should().Be(2); + } + + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + public void EntryDefensivelyFoldsConsentAndReleasesContext(bool first, bool second) + { + var entry = new EvaluationEntry(20, first, new Dictionary { ["pii"] = "canary" }); + + entry.Observe(10, second); + + entry.ObserveFullEvaluationData.Should().BeFalse(); + entry.ContextAttrs.Should().BeNull(); + entry.Count.Should().Be(2); + entry.FirstEvaluationMs.Should().Be(10); + entry.LastEvaluationMs.Should().Be(20); + } + + private static FlagEvalEvent Observation(string flag = "flag", string? variant = "on", string? target = "subject", long time = 10, bool consent = false, IReadOnlyDictionary? attrs = null, string? error = null) => + new(flag, variant, "allocation", target, time, attrs, error, consent); + + private sealed class ExplosiveValue + { + public override string ToString() => throw new InvalidOperationException("pii-canary"); + } + + private sealed class ThrowingAttributes : IReadOnlyDictionary + { + public IEnumerable Keys => throw new InvalidOperationException(); + + public IEnumerable Values => throw new InvalidOperationException(); + + public int Count => throw new InvalidOperationException(); + + public object? this[string key] => throw new InvalidOperationException(); + + public bool ContainsKey(string key) => throw new InvalidOperationException(); + + public bool TryGetValue(string key, out object? value) => throw new InvalidOperationException(); + + public IEnumerator> GetEnumerator() => throw new InvalidOperationException(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationContextTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationContextTests.cs new file mode 100644 index 000000000000..0121764336a5 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationContextTests.cs @@ -0,0 +1,102 @@ +// +// 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; +using System.Collections.Generic; +using System.Globalization; +using Datadog.Trace.FeatureFlags.FlagEvaluation; +using FluentAssertions; +using Xunit; + +namespace Datadog.Trace.Tests.FeatureFlags; + +public class FlagEvaluationContextTests +{ + [Fact] + public void DoesNotEnumerateBeyondTheBoundedSubset() + { + var attributes = new CountingAttributes(); + + var copy = FlagEvaluationContext.Copy(attributes, out var omissions); + + copy.Should().HaveCount(256); + attributes.Visited.Should().Be(256); + omissions.Should().Be(ContextOmissionReason.MaxContextFields); + } + + [Fact] + public void RejectsMalformedUnsupportedAndOversizedScalars() + { + var attributes = new Dictionary + { + ["valid"] = "text", + ["\uD800"] = "invalid key", + ["invalid value"] = "\uD800", + [new string('k', 257)] = true, + ["long value"] = new string('v', 257), + ["nan"] = double.NaN, + ["infinity"] = float.PositiveInfinity, + ["nested"] = new Dictionary(), + ["null"] = null, + }; + + var copy = FlagEvaluationContext.Copy(attributes, out var omissions); + + copy.Should().ContainSingle().Which.Should().Be(new KeyValuePair("valid", "text")); + omissions.Should().Be(ContextOmissionReason.UnsupportedValue | ContextOmissionReason.MaxKeyLength | ContextOmissionReason.MaxValueLength); + } + + [Fact] + public void CanonicalKeyIsCultureIndependentAndUnambiguous() + { + var previous = CultureInfo.CurrentCulture; + try + { + var attributes = new Dictionary { ["value"] = 1.25d, ["decimal"] = 1.25m }; + CultureInfo.CurrentCulture = new CultureInfo("en-US"); + var expected = FlagEvaluationContext.CanonicalKey(attributes); + CultureInfo.CurrentCulture = new CultureInfo("fr-FR"); + + FlagEvaluationContext.CanonicalKey(attributes).Should().Be(expected); + FlagEvaluationContext.CanonicalKey(new Dictionary { ["a"] = "b:c" }) + .Should().NotBe(FlagEvaluationContext.CanonicalKey(new Dictionary { ["a:b"] = "c" })); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } + + private sealed class CountingAttributes : IReadOnlyDictionary + { + public int Count => 1000; + + public int Visited { get; private set; } + + public IEnumerable Keys => throw new NotSupportedException(); + + public IEnumerable Values => throw new NotSupportedException(); + + public object? this[string key] => throw new NotSupportedException(); + + public bool ContainsKey(string key) => throw new NotSupportedException(); + + public bool TryGetValue(string key, out object? value) => throw new NotSupportedException(); + + public IEnumerator> GetEnumerator() + { + for (var i = 0; i < Count; i++) + { + Visited++; + yield return new KeyValuePair(i.ToString(CultureInfo.InvariantCulture), true); + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationPayloadTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationPayloadTests.cs new file mode 100644 index 000000000000..7643f4d6a098 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationPayloadTests.cs @@ -0,0 +1,300 @@ +// +// 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 System.Linq; +using System.Text; +using Datadog.Trace.FeatureFlags.FlagEvaluation; +using Datadog.Trace.Vendors.Newtonsoft.Json.Linq; +using FluentAssertions; +using FluentAssertions.Execution; +using Xunit; + +namespace Datadog.Trace.Tests.FeatureFlags; + +public class FlagEvaluationPayloadTests +{ + private const long Epoch = 1780000000000; + private const string TargetCanary = "pii-subject-canary@example.com"; + private const string ContextCanary = "pii-context-canary"; + private const string ErrorCanary = "pii-error-canary"; + private static readonly IReadOnlyDictionary ServiceContext = new Dictionary { ["service"] = "sdk-test", ["env"] = "test", ["version"] = "1" }; + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void ExactWireBytesRespectConsentAndTier(bool consent, bool degraded) + { + var aggregator = new FlagEvaluationAggregator(degraded ? 0 : 10, 10, 10); + aggregator.Add(new FlagEvalEvent("flag", "on", "allocation", TargetCanary, Epoch, new Dictionary { ["country"] = ContextCanary }, ErrorCanary, consent)); + + var result = Encode(aggregator.Drain()); + var bytes = result.Payloads.Should().ContainSingle().Subject; + + ContainsBytes(bytes, TargetCanary).Should().Be(consent && !degraded); + ContainsBytes(bytes, ContextCanary).Should().Be(consent && !degraded); + ContainsBytes(bytes, ErrorCanary).Should().BeFalse(); + var batch = JObject.Parse(Encoding.UTF8.GetString(bytes)); + batch.Properties().Select(p => p.Name).Should().BeEquivalentTo("context", "flagEvaluations"); + batch["context"]!.ToObject>().Should().BeEquivalentTo(ServiceContext); + var row = (JObject)batch["flagEvaluations"]!.Single(); + AssertRequiredShape(row); + row["error"]!["message"]!.Value().Should().Be("GENERAL"); + row["variant"]!["key"]!.Value().Should().Be("on"); + row["allocation"]!["key"]!.Value().Should().Be("allocation"); + row["runtime_default_used"].Should().BeNull(); + row["targeting_rule"].Should().BeNull(); + row["reason"].Should().BeNull(); + if (degraded) + { + row["targeting_key"].Should().BeNull(); + row["context"].Should().BeNull(); + } + else + { + row["targeting_key"]!.Value().Should().Be(FlagEvaluationPrivacy.TargetingKeyForOutput(TargetCanary, consent)); + } + + result.PayloadDroppedEvaluations.Should().Be(0); + result.SerializationDroppedEvaluations.Should().Be(0); + } + + [Theory] + [InlineData(false, true)] + [InlineData(true, false)] + public void SerializerIndependentlyProtectsOverPermissiveEntries(bool keyConsent, bool entryConsent) + { + var key = new FullKey(new DegradedKey("flag", "on", null, ErrorCanary), TargetCanary, "ignored", keyConsent); + var entry = new EvaluationEntry(Epoch, entryConsent, new Dictionary { ["secret"] = ContextCanary }); + var state = new DrainResult(new Dictionary { [key] = entry }, [], 0); + + var bytes = Encode(state).Payloads.Should().ContainSingle().Subject; + + using var scope = new AssertionScope(); + ContainsBytes(bytes, TargetCanary).Should().BeFalse(); + ContainsBytes(bytes, ContextCanary).Should().BeFalse(); + ContainsBytes(bytes, ErrorCanary).Should().BeFalse(); + var row = Rows(bytes).Single(); + row["targeting_key"]!.Value().Should().Be(FlagEvaluationPrivacy.TargetingKeyForOutput(TargetCanary, false)); + row["error"]!["message"]!.Value().Should().Be("GENERAL"); + row["context"].Should().BeNull(); + } + + [Fact] + public void SerializerAlwaysOmitsDegradedContextEvenWhenEntryIsMalformed() + { + var entry = new EvaluationEntry(Epoch, true, new Dictionary { ["secret"] = ContextCanary }); + var state = new DrainResult([], new Dictionary { [new("flag", "on", null, ErrorCanary)] = entry }, 0); + + var bytes = Encode(state).Payloads.Should().ContainSingle().Subject; + + ContainsBytes(bytes, ContextCanary).Should().BeFalse(); + ContainsBytes(bytes, ErrorCanary).Should().BeFalse(); + Rows(bytes).Single()["context"].Should().BeNull(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SerializerIndependentlyOmitsMalformedTarget(bool consent) + { + var key = new FullKey(new("flag", "on", null, null), "\uD800", string.Empty, consent); + var state = new DrainResult(new Dictionary { [key] = new(Epoch, consent, null) }, [], 0); + + var result = Encode(state); + + Rows(result.Payloads.Single()).Single()["targeting_key"].Should().BeNull(); + result.SerializationDroppedEvaluations.Should().Be(0); + state.InvalidTargetingKeys.Should().Be(0); // This bypassed admission; output does not double-count. + } + + [Fact] + public void SerializerBoundsAndNormalizesInjectedConsentedAttributes() + { + var attributes = new Dictionary + { + ["nested"] = new Dictionary { ["pii"] = ContextCanary }, + ["not-finite"] = double.NaN, + ["oversized"] = new string('x', 257), + ["valid"] = true, + }; + for (var i = 0; i < 300; i++) + { + attributes["field-" + i] = i; + } + + var key = new FullKey(new("flag", "on", null, null), TargetCanary, string.Empty, true); + var state = new DrainResult(new Dictionary { [key] = new(Epoch, true, attributes) }, [], 0); + + var bytes = Encode(state).Payloads.Single(); + + ContainsBytes(bytes, ContextCanary).Should().BeFalse(); + var context = (JObject)Rows(bytes).Single()["context"]!["evaluation"]!; + context.Properties().Count().Should().BeLessThanOrEqualTo(256); + context["valid"]!.Value().Should().BeTrue(); + context["nested"].Should().BeNull(); + context["not-finite"].Should().BeNull(); + context["oversized"].Should().BeNull(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("quoted\"\nvariant")] + public void RuntimeDefaultAndJsonEscapingPreserveVariantSemantics(string? variant) + { + var aggregator = new FlagEvaluationAggregator(10, 10, 10); + aggregator.Add(new FlagEvalEvent("flag\"\nkey", variant, null, string.Empty, Epoch, null)); + + var row = Rows(Encode(aggregator.Drain()).Payloads.Single()).Single(); + + row["flag"]!["key"]!.Value().Should().Be("flag\"\nkey"); + row["targeting_key"]!.Value().Should().BeEmpty(); + row["allocation"].Should().BeNull(); + row["error"].Should().BeNull(); + if (variant is null) + { + row["variant"].Should().BeNull(); + row["runtime_default_used"]!.Value().Should().BeTrue(); + } + else + { + row["variant"]!["key"]!.Value().Should().Be(variant); + row["runtime_default_used"].Should().BeNull(); + } + } + + [Fact] + public void SplitsBatchesUsingEncodedByteSize() + { + var single = new FlagEvaluationAggregator(10, 10, 10); + single.Add(new FlagEvalEvent("one", "on", null, null, Epoch, null)); + var limit = Encode(single.Drain()).Payloads.Single().Length; + var aggregator = new FlagEvaluationAggregator(10, 10, 10); + foreach (var flag in new[] { "one", "two", "six" }) + { + aggregator.Add(new FlagEvalEvent(flag, "on", null, null, Epoch, null)); + } + + var result = Encode(aggregator.Drain(), limit); + + result.Payloads.Should().HaveCount(3); + result.Payloads.Should().OnlyContain(p => p.Length <= limit); + result.Payloads.SelectMany(Rows).Sum(r => r["evaluation_count"]!.Value()).Should().Be(3); + } + + [Fact] + public void NonAsciiBytesAreNotMistakenForCharacterCount() + { + var aggregator = new FlagEvaluationAggregator(10, 10, 10); + aggregator.Add(new FlagEvalEvent(new string('é', 100), "on", null, null, Epoch, null)); + var state = aggregator.Drain(); + var bytes = Encode(state).Payloads.Single(); + var characterCount = Encoding.UTF8.GetString(bytes).Length; + + var result = Encode(state, characterCount); + + bytes.Length.Should().BeGreaterThan(characterCount); + result.Payloads.Should().BeEmpty(); + result.PayloadDroppedEvaluations.Should().Be(1); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void OversizedRowDegradesOrDropsWithoutLosingItsNeighbors(bool irreducible) + { + var aggregator = new FlagEvaluationAggregator(10, 10, 10); + aggregator.Add(new FlagEvalEvent("before", "on", null, null, Epoch, null)); + for (var i = 0; i < 3; i++) + { + aggregator.Add(new FlagEvalEvent(irreducible ? new string('f', 5000) : "large", "on", null, new string('t', 5000), Epoch, null, observeFullEvaluationData: true)); + } + + aggregator.Add(new FlagEvalEvent("after", "on", null, null, Epoch, null)); + + var result = Encode(aggregator.Drain(), 512); + var rows = result.Payloads.SelectMany(Rows).ToList(); + + result.Payloads.Should().OnlyContain(p => p.Length <= 512); + rows.Select(r => r["flag"]!["key"]!.Value()).Should().Contain("before").And.Contain("after"); + rows.Sum(r => r["evaluation_count"]!.Value()).Should().Be(irreducible ? 2 : 5); + result.PayloadDegradedEvaluations.Should().Be(irreducible ? 0 : 3); + result.PayloadDroppedEvaluations.Should().Be(irreducible ? 3 : 0); + result.SerializationDroppedEvaluations.Should().Be(0); + if (!irreducible) + { + rows.Single(r => r["flag"]!["key"]!.Value() == "large")["targeting_key"].Should().BeNull(); + } + } + + [Fact] + public void MalformedRowDoesNotPoisonOtherRows() + { + var good = new FullKey(new("good", "on", null, null), null, string.Empty, false); + var bad = new FullKey(new(null!, "on", null, null), null, string.Empty, false); + var badEntry = new EvaluationEntry(Epoch, false, null); + badEntry.Observe(Epoch, false); + var state = new DrainResult(new Dictionary { [bad] = badEntry, [good] = new(Epoch, false, null) }, [], 0); + + var result = Encode(state); + + Rows(result.Payloads.Single()).Single()["flag"]!["key"]!.Value().Should().Be("good"); + result.SerializationDroppedEvaluations.Should().Be(2); + result.PayloadDroppedEvaluations.Should().Be(0); + } + + [Fact] + public void EmptyDrainDoesNotEmitEmptyPayload() + { + Encode(new DrainResult([], [], 4)).Payloads.Should().BeEmpty(); + } + + private static FlagEvaluationPayloadResult Encode(DrainResult state, int limit = 5 * 1024 * 1024) => FlagEvaluationPayload.Encode(state, ServiceContext, Epoch + 100, limit); + + private static IEnumerable Rows(byte[] bytes) => JObject.Parse(Encoding.UTF8.GetString(bytes))["flagEvaluations"]!.Children(); + + private static bool ContainsBytes(byte[] bytes, string text) + { + var needle = Encoding.UTF8.GetBytes(text); + for (var start = 0; start <= bytes.Length - needle.Length; start++) + { + var offset = 0; + while (offset < needle.Length && bytes[start + offset] == needle[offset]) + { + offset++; + } + + if (offset == needle.Length) + { + return true; + } + } + + return false; + } + + private static void AssertRequiredShape(JObject row) + { + // From flageval-worker batchedflagevaluations.json at dd-source 3a0aba914679. + row.Properties().Select(p => p.Name).Should().OnlyContain(name => new[] + { + "timestamp", "flag", "first_evaluation", "last_evaluation", "evaluation_count", + "variant", "allocation", "runtime_default_used", "targeting_key", "context", "error", + }.Contains(name)); + row["timestamp"]!.Type.Should().Be(JTokenType.Integer); + row["timestamp"]!.Value().Should().Be(Epoch + 100); + row["first_evaluation"]!.Value().Should().Be(Epoch); + row["last_evaluation"]!.Value().Should().Be(Epoch); + row["evaluation_count"]!.Value().Should().Be(1); + row["flag"]!.Type.Should().Be(JTokenType.Object); + row["flag"]!["key"]!.Value().Should().Be("flag"); + } +} diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationPrivacyTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationPrivacyTests.cs new file mode 100644 index 000000000000..b3a388b44180 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationPrivacyTests.cs @@ -0,0 +1,84 @@ +// +// 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.FeatureFlags.FlagEvaluation; +using FluentAssertions; +using Xunit; + +namespace Datadog.Trace.Tests.FeatureFlags; + +public class FlagEvaluationPrivacyTests +{ + [Theory] + [InlineData("jane.doe@datadoghq.com", "sha256_b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b")] + [InlineData("user-123", "sha256_fcdec6df4d44dbc637c7c5b58efface52a7f8a88535423430255be0bb89bedd8")] + [InlineData("", "")] + [InlineData(null, null)] + public void ProtectedTargetMatchesCrossSdkContract(string? input, string? expected) + { + FlagEvaluationPrivacy.TargetingKeyForOutput(input, false).Should().Be(expected); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void InvalidUtf16IsOmittedInsteadOfRepaired(bool consent) + { + FlagEvaluationPrivacy.TargetingKeyForOutput("\uD800", consent).Should().BeNull(); + FlagEvaluationPrivacy.TargetingKeyForOutput("prefix\uDC00suffix", consent).Should().BeNull(); + } + + [Theory] + [InlineData("")] + [InlineData(" Subject ")] + [InlineData("é😀")] + [InlineData("sha256_customer-input")] + [InlineData(null)] + public void FullConsentPreservesExactValidText(string? input) + { + FlagEvaluationPrivacy.TargetingKeyForOutput(input, true).Should().Be(input); + } + + [Fact] + public void ProtectedHashDoesNotNormalizeOrTrustHashLookingInput() + { + FlagEvaluationPrivacy.TargetingKeyForOutput(" Subject ", false).Should().NotBe(FlagEvaluationPrivacy.TargetingKeyForOutput("Subject", false)); + FlagEvaluationPrivacy.TargetingKeyForOutput("Subject", false).Should().NotBe(FlagEvaluationPrivacy.TargetingKeyForOutput("subject", false)); + FlagEvaluationPrivacy.TargetingKeyForOutput("é", false).Should().NotBe(FlagEvaluationPrivacy.TargetingKeyForOutput("e\u0301", false)); + FlagEvaluationPrivacy.TargetingKeyForOutput("sha256_customer-input", false).Should().StartWith("sha256_").And.HaveLength(71).And.NotBe("sha256_customer-input"); + } + + [Theory] + [InlineData("FLAG_NOT_FOUND")] + [InlineData("INVALID_CONTEXT")] + [InlineData("PARSE_ERROR")] + [InlineData("PROVIDER_FATAL")] + [InlineData("PROVIDER_NOT_READY")] + [InlineData("TARGETING_KEY_MISSING")] + [InlineData("TYPE_MISMATCH")] + [InlineData("GENERAL")] + [InlineData(null)] + public void KnownErrorsAndAbsenceArePreserved(string? code) + { + FlagEvaluationPrivacy.ErrorCodeForOutput(code).Should().Be(code); + } + + [Theory] + [InlineData("private diagnostic")] + [InlineData("general")] + [InlineData(" GENERAL ")] + public void UnknownErrorsAreSanitized(string code) + { + FlagEvaluationPrivacy.ErrorCodeForOutput(code).Should().Be("GENERAL"); + } + + [Fact] + public void EmptyErrorIsAbsentLikeOtherSdks() + { + FlagEvaluationPrivacy.ErrorCodeForOutput(string.Empty).Should().BeNull(); + } +} From 0dcca8f235933259a7787350a12c4e5dc9d699fd Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Wed, 30 Sep 2026 16:54:57 +0000 Subject: [PATCH 04/15] feat(ffe): add Agent-only flagevaluation sender --- .../FlagEvaluationAgentHeaderHelper.cs | 33 +++ .../FlagEvaluationAgentSender.cs | 84 ++++++ .../FlagEvaluationAgentSenderTests.cs | 271 ++++++++++++++++++ 3 files changed, 388 insertions(+) create mode 100644 tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAgentHeaderHelper.cs create mode 100644 tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAgentSender.cs create mode 100644 tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationAgentSenderTests.cs diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAgentHeaderHelper.cs b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAgentHeaderHelper.cs new file mode 100644 index 000000000000..b71dde839f02 --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAgentHeaderHelper.cs @@ -0,0 +1,33 @@ +// +// 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.Collections.Generic; +using Datadog.Trace.HttpOverStreams; + +namespace Datadog.Trace.FeatureFlags.FlagEvaluation; + +internal sealed class FlagEvaluationAgentHeaderHelper : HttpHeaderHelperBase +{ + internal static readonly FlagEvaluationAgentHeaderHelper Instance = new(); + + private FlagEvaluationAgentHeaderHelper() + { + DefaultHeaders = + [ + ..AgentHttpHeaderNames.MinimalHeaders, + new("X-Datadog-EVP-Subdomain", "event-platform-intake"), + new("DD-EVP-ORIGIN", "dd-trace-dotnet"), + new("DD-EVP-ORIGIN-VERSION", TracerConstants.ThreePartVersion), + ]; + HttpSerializedDefaultHeaders = AgentHttpHeaderNames.HttpSerializedMinimalHeaders + + "X-Datadog-EVP-Subdomain: event-platform-intake\r\n" + + "DD-EVP-ORIGIN: dd-trace-dotnet\r\n" + + "DD-EVP-ORIGIN-VERSION: " + TracerConstants.ThreePartVersion + "\r\n"; + } + + public override KeyValuePair[] DefaultHeaders { get; } + + protected override string HttpSerializedDefaultHeaders { get; } +} diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAgentSender.cs b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAgentSender.cs new file mode 100644 index 000000000000..acd78ce4e4c8 --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAgentSender.cs @@ -0,0 +1,84 @@ +// +// 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.Threading; +using System.Threading.Tasks; +using Datadog.Trace.Agent; +using Datadog.Trace.Configuration; +using Datadog.Trace.Logging; + +namespace Datadog.Trace.FeatureFlags.FlagEvaluation; + +internal sealed class FlagEvaluationAgentSender : IDisposable +{ + private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(); + private readonly object _settingsLock = new(); + private IApiRequestFactory? _factory; + + internal FlagEvaluationAgentSender(ExporterSettings settings) + : this(CreateFactory(settings)) + { + } + + internal FlagEvaluationAgentSender(IApiRequestFactory factory) + { + _factory = factory; + } + + internal void UpdateExporterSettings(ExporterSettings settings) + { + lock (_settingsLock) + { + if (_factory is not null) + { + Volatile.Write(ref _factory, CreateFactory(settings)); + } + } + } + + internal async Task SendCompressedAsync(ArraySegment payload) + { + // A settings update or close cannot change the factory of an admitted request. + var factory = Volatile.Read(ref _factory); + if (factory is null) + { + return; + } + + try + { + var request = factory.Create(factory.GetEndpoint("evp_proxy/v2/api/v2/flagevaluation")); + using var response = await request.PostAsync(payload, "application/json", "gzip").ConfigureAwait(false); + if (response.StatusCode is < 200 or >= 300) + { + Log.Debug("FeatureFlags flagevaluation Agent request failed with HTTP status {StatusCode}; dropping this batch without retry.", response.StatusCode); + } + } + catch (Exception) + { + // Exception messages and response bodies can contain customer data. + Log.Debug("FeatureFlags flagevaluation Agent request failed; dropping this batch without retry."); + } + } + + public void Dispose() + { + lock (_settingsLock) + { + // Existing factories have no disposal contract. Let in-flight requests finish; + // clearing our reference closes admission without retaining the last factory. + Volatile.Write(ref _factory, null); + } + } + + private static IApiRequestFactory CreateFactory(ExporterSettings settings) => AgentTransportStrategy.Get( + settings, + productName: "FeatureFlags flagevaluation", + tcpTimeout: TimeSpan.FromSeconds(5), + httpHeaderHelper: FlagEvaluationAgentHeaderHelper.Instance); +} diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationAgentSenderTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationAgentSenderTests.cs new file mode 100644 index 000000000000..691ece1bc997 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationAgentSenderTests.cs @@ -0,0 +1,271 @@ +// +// 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 System.Collections.Specialized; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Datadog.Trace.Agent; +using Datadog.Trace.Configuration; +using Datadog.Trace.FeatureFlags.FlagEvaluation; +using Datadog.Trace.HttpOverStreams; +using Datadog.Trace.HttpOverStreams.HttpContent; +using Datadog.Trace.TestHelpers; +using FluentAssertions; +using Moq; +using Xunit; +using Xunit.Abstractions; + +namespace Datadog.Trace.Tests.FeatureFlags; + +[Collection(nameof(WebRequestCollection))] +public class FlagEvaluationAgentSenderTests(ITestOutputHelper output) +{ + [Theory] + [InlineData(202)] + [InlineData(400)] + [InlineData(404)] + [InlineData(405)] + [InlineData(429)] + [InlineData(500)] + public async Task SendsEachBatchOnceAndDisposesResponse(int statusCode) + { + var factory = new Mock(MockBehavior.Strict); + var request = new Mock(MockBehavior.Strict); + var response = new Mock(MockBehavior.Strict); + response.SetupGet(x => x.StatusCode).Returns(statusCode); + response.Setup(x => x.Dispose()); + var uri = new Uri("http://127.0.0.1:8126/evp_proxy/v2/api/v2/flagevaluation"); + var payload = new ArraySegment(new byte[] { 1, 2, 3 }); + factory.Setup(x => x.GetEndpoint("evp_proxy/v2/api/v2/flagevaluation")).Returns(uri); + factory.Setup(x => x.Create(uri)).Returns(request.Object); + request.Setup(x => x.PostAsync(payload, "application/json", "gzip")).ReturnsAsync(response.Object); + + using var sender = new FlagEvaluationAgentSender(factory.Object); + await sender.SendCompressedAsync(payload); + + request.Verify(x => x.PostAsync(payload, "application/json", "gzip"), Times.Once); + response.Verify(x => x.Dispose(), Times.Once); + response.Verify(x => x.GetStreamAsync(), Times.Never); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FailedSendIsNotReplayedAndNextBatchStillWorks(bool timeout) + { + var factory = new Mock(); + var request = new Mock(); + var response = new Mock(); + response.SetupGet(x => x.StatusCode).Returns(202); + var uri = new Uri("http://127.0.0.1:8126/evp_proxy/v2/api/v2/flagevaluation"); + factory.Setup(x => x.GetEndpoint(It.IsAny())).Returns(uri); + factory.Setup(x => x.Create(uri)).Returns(request.Object); + Exception error = timeout ? new TaskCanceledException("private-canary") : new IOException("private-canary"); + request.SetupSequence(x => x.PostAsync(It.IsAny>(), "application/json", "gzip")) + .ThrowsAsync(error) + .ReturnsAsync(response.Object); + + using var sender = new FlagEvaluationAgentSender(factory.Object); + var first = new ArraySegment(new byte[] { 1 }); + var second = new ArraySegment(new byte[] { 2 }); + await sender.SendCompressedAsync(first); + await sender.SendCompressedAsync(second); + + request.Verify(x => x.PostAsync(first, "application/json", "gzip"), Times.Once); + request.Verify(x => x.PostAsync(second, "application/json", "gzip"), Times.Once); + response.Verify(x => x.Dispose(), Times.Once); + } + + [Fact] + public async Task DisposeDoesNotInterruptInFlightSendAndPreventsNewSends() + { + var factory = new Mock(MockBehavior.Strict); + var request = new Mock(MockBehavior.Strict); + var response = new Mock(); + response.SetupGet(x => x.StatusCode).Returns(202); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var uri = new Uri("http://127.0.0.1:8126/evp_proxy/v2/api/v2/flagevaluation"); + factory.Setup(x => x.GetEndpoint(It.IsAny())).Returns(uri); + factory.Setup(x => x.Create(uri)).Returns(request.Object); + request.Setup(x => x.PostAsync(It.IsAny>(), "application/json", "gzip")).Returns(pending.Task); + + using var sender = new FlagEvaluationAgentSender(factory.Object); + var send = sender.SendCompressedAsync(new ArraySegment(new byte[] { 1 })); + sender.Dispose(); + sender.Dispose(); + send.IsCompleted.Should().BeFalse(); + await sender.SendCompressedAsync(new ArraySegment(new byte[] { 2 })); + pending.SetResult(response.Object); + await send; + + request.Verify(x => x.PostAsync(It.IsAny>(), "application/json", "gzip"), Times.Once); + response.Verify(x => x.Dispose(), Times.Once); + } + + [Fact] + public async Task StreamHeadersCarryProducerIdentityWithoutCredentials() + { + var helper = FlagEvaluationAgentHeaderHelper.Instance; + helper.DefaultHeaders.Should().Contain(new KeyValuePair("X-Datadog-EVP-Subdomain", "event-platform-intake")); + helper.DefaultHeaders.Should().Contain(new KeyValuePair("DD-EVP-ORIGIN", "dd-trace-dotnet")); + helper.DefaultHeaders.Should().Contain(new KeyValuePair("DD-EVP-ORIGIN-VERSION", TracerConstants.ThreePartVersion)); + var request = new HttpRequest("POST", "localhost", "/evp_proxy/v2/api/v2/flagevaluation", new HttpHeaders(), new BufferContent(new ArraySegment(new byte[] { 1 }))); + using var text = new StringWriter(); + await helper.WriteLeadingHeaders(request, text); + text.ToString().Should().Contain("X-Datadog-EVP-Subdomain: event-platform-intake\r\n") + .And.Contain("DD-EVP-ORIGIN: dd-trace-dotnet\r\n") + .And.Contain("DD-EVP-ORIGIN-VERSION: " + TracerConstants.ThreePartVersion + "\r\n") + .And.NotContain("DD-API-KEY", "the event sender must not forward configuration-fetch credentials"); + } + + [Theory] + [InlineData("remote_config")] + [InlineData("agentless")] + public async Task ProductionFactorySendsGzipToAgentWithoutConfigurationApiKey(string source) + { + using var agent = MockTracerAgent.Create(output); + var received = new List(); + agent.EventPlatformProxyPayloadReceived += (_, args) => received.Add(args.Value); + var settings = CreateSettings($"http://127.0.0.1:{agent.Port}", source); + using var sender = new FlagEvaluationAgentSender(settings.Manager.InitialExporterSettings); + await sender.SendCompressedAsync(Compress("{\"events\":[]}")); + + received.Should().ContainSingle(); + received[0].PathAndQuery.Should().Be("/evp_proxy/v2/api/v2/flagevaluation"); + received[0].Headers["Content-Encoding"].Should().Be("gzip"); + received[0].Headers["Content-Type"].Should().Be("application/json"); + received[0].Headers["X-Datadog-EVP-Subdomain"].Should().Be("event-platform-intake"); + received[0].Headers["DD-EVP-ORIGIN"].Should().Be("dd-trace-dotnet"); + received[0].Headers["DD-EVP-ORIGIN-VERSION"].Should().Be(TracerConstants.ThreePartVersion); + received[0].Headers["DD-API-KEY"].Should().BeNull(); + received[0].Headers["Authorization"].Should().BeNull(); + received[0].BodyInJson.Should().Be("{\"events\":[]}"); + } + + [Fact] + public async Task ExporterChangeUsesNewAgentWithoutInterruptingInFlightSend() + { + var factory = new Mock(); + var request = new Mock(); + var response = new Mock(); + response.SetupGet(x => x.StatusCode).Returns(202); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var uri = new Uri("http://127.0.0.1:8126/evp_proxy/v2/api/v2/flagevaluation"); + factory.Setup(x => x.GetEndpoint(It.IsAny())).Returns(uri); + factory.Setup(x => x.Create(uri)).Returns(request.Object); + request.Setup(x => x.PostAsync(It.IsAny>(), "application/json", "gzip")).Returns(pending.Task); + using var sender = new FlagEvaluationAgentSender(factory.Object); + var originalSend = sender.SendCompressedAsync(Compress("{}")); + using var agent = MockTracerAgent.Create(output); + var received = new List(); + agent.EventPlatformProxyPayloadReceived += (_, args) => received.Add(args.Value); + + sender.UpdateExporterSettings(CreateSettings($"http://127.0.0.1:{agent.Port}").Manager.InitialExporterSettings); + await sender.SendCompressedAsync(Compress("{\"new\":true}")); + received.Should().ContainSingle(); + received[0].BodyInJson.Should().Be("{\"new\":true}"); + originalSend.IsCompleted.Should().BeFalse(); + pending.SetResult(response.Object); + await originalSend; + response.Verify(x => x.Dispose(), Times.Once); + + sender.Dispose(); + sender.UpdateExporterSettings(CreateSettings($"http://127.0.0.1:{agent.Port}").Manager.InitialExporterSettings); + await sender.SendCompressedAsync(Compress("{\"afterDispose\":true}")); + received.Should().ContainSingle("exporter changes must not resurrect a disposed sender"); + } + + [Fact] + public async Task SlowTcpAgentTimesOutWithoutReplayingBatch() + { + using var release = new ManualResetEventSlim(); + using var agent = MockTracerAgent.Create(output); + var requests = 0; + agent.EventPlatformProxyPayloadReceived += (_, _) => + { + Interlocked.Increment(ref requests); + release.Wait(TimeSpan.FromSeconds(20)); + }; + using var sender = new FlagEvaluationAgentSender(CreateSettings($"http://127.0.0.1:{agent.Port}").Manager.InitialExporterSettings); + var elapsed = Stopwatch.StartNew(); + var send = sender.SendCompressedAsync(Compress("{}")); + try + { + (await Task.WhenAny(send, Task.Delay(TimeSpan.FromSeconds(15)))).Should().BeSameAs(send); + await send; + elapsed.Elapsed.Should().BeGreaterThan(TimeSpan.FromSeconds(4)).And.BeLessThan(TimeSpan.FromSeconds(15)); + Volatile.Read(ref requests).Should().Be(1); + } + finally + { + release.Set(); + } + } + +#if NETCOREAPP3_1_OR_GREATER + [Fact] + public async Task ConfiguredUnixSocketReceivesAgentPayload() + { + using var agent = MockTracerAgent.Create(output, new UnixDomainSocketConfig(Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()), null)); + var received = new List(); + agent.EventPlatformProxyPayloadReceived += (_, args) => received.Add(args.Value); + using var sender = new FlagEvaluationAgentSender(CreateSettings("unix://" + agent.TracesUdsPath).Manager.InitialExporterSettings); + await sender.SendCompressedAsync(Compress("{}")); + received.Should().ContainSingle(); + received[0].PathAndQuery.Should().Be("/evp_proxy/v2/api/v2/flagevaluation"); + received[0].Headers["DD-EVP-ORIGIN"].Should().Be("dd-trace-dotnet"); + received[0].Headers["DD-EVP-ORIGIN-VERSION"].Should().Be(TracerConstants.ThreePartVersion); + received[0].BodyInJson.Should().Be("{}"); + } +#endif + + [SkippableFact] + [Trait("Category", "LinuxUnsupported")] + public async Task ConfiguredNamedPipeReceivesAgentPayload() + { + SkipOn.AllExcept(SkipOn.PlatformValue.Windows); + using var agent = MockTracerAgent.Create(output, new WindowsPipesConfig($"trace-{Guid.NewGuid()}", null)); + var received = new List(); + agent.EventPlatformProxyPayloadReceived += (_, args) => received.Add(args.Value); + var settings = new TracerSettings(new NameValueConfigurationSource(new NameValueCollection + { + { "DD_TRACE_PIPE_NAME", agent.TracesWindowsPipeName }, + })); + using var sender = new FlagEvaluationAgentSender(settings.Manager.InitialExporterSettings); + await sender.SendCompressedAsync(Compress("{}")); + received.Should().ContainSingle(); + received[0].PathAndQuery.Should().Be("/evp_proxy/v2/api/v2/flagevaluation"); + received[0].Headers["DD-EVP-ORIGIN"].Should().Be("dd-trace-dotnet"); + received[0].Headers["DD-EVP-ORIGIN-VERSION"].Should().Be(TracerConstants.ThreePartVersion); + received[0].BodyInJson.Should().Be("{}"); + } + + private static TracerSettings CreateSettings(string agentUrl, string source = "remote_config") => new(new NameValueConfigurationSource(new NameValueCollection + { + { "DD_TRACE_AGENT_URL", agentUrl }, + { "DD_API_KEY", "configuration-credential-canary" }, + { ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSource, source }, + })); + + private static ArraySegment Compress(string json) + { + using var stream = new MemoryStream(); + using (var gzip = new GZipStream(stream, CompressionMode.Compress, leaveOpen: true)) + { + var bytes = Encoding.UTF8.GetBytes(json); + gzip.Write(bytes, 0, bytes.Length); + } + + return new ArraySegment(stream.ToArray()); + } +} From 7823738dd47b20f9c981339964797b5106e1eecd Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Wed, 30 Sep 2026 17:43:48 +0000 Subject: [PATCH 05/15] feat(ffe): add bounded background EVP delivery --- .../FeatureFlags/FeatureFlagsModule.cs | 128 +++- .../FlagEvaluation/FlagEvaluationTelemetry.cs | 116 ++++ .../FlagEvaluation/FlagEvaluationWriter.cs | 330 ++++++++++ ...bilityMetricsTelemetryCollector_Count.g.cs | 24 + .../CountExtensions.g.cs | 8 +- .../IMetricsTelemetryCollector_Count.g.cs | 12 + .../MetricsTelemetryCollector_Count.g.cs | 64 +- .../NullMetricsTelemetryCollector_Count.g.cs | 24 + ...bilityMetricsTelemetryCollector_Count.g.cs | 24 + .../CountExtensions.g.cs | 8 +- .../IMetricsTelemetryCollector_Count.g.cs | 12 + .../MetricsTelemetryCollector_Count.g.cs | 64 +- .../NullMetricsTelemetryCollector_Count.g.cs | 24 + ...bilityMetricsTelemetryCollector_Count.g.cs | 24 + .../CountExtensions.g.cs | 8 +- .../IMetricsTelemetryCollector_Count.g.cs | 12 + .../MetricsTelemetryCollector_Count.g.cs | 64 +- .../NullMetricsTelemetryCollector_Count.g.cs | 24 + ...bilityMetricsTelemetryCollector_Count.g.cs | 24 + .../CountExtensions.g.cs | 8 +- .../IMetricsTelemetryCollector_Count.g.cs | 12 + .../MetricsTelemetryCollector_Count.g.cs | 64 +- .../NullMetricsTelemetryCollector_Count.g.cs | 24 + .../Datadog.Trace/Telemetry/Metrics/Count.cs | 21 + .../Telemetry/Metrics/MetricTags.cs | 35 + .../FeatureFlags/FlagEvaluationModuleTests.cs | 184 ++++++ .../FeatureFlags/FlagEvaluationWriterTests.cs | 607 ++++++++++++++++++ .../Telemetry/Metrics/common_metrics.json | 48 ++ 28 files changed, 1973 insertions(+), 24 deletions(-) create mode 100644 tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationTelemetry.cs create mode 100644 tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationWriter.cs create mode 100644 tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationModuleTests.cs create mode 100644 tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationWriterTests.cs diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsModule.cs b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsModule.cs index b6b96e7e94f3..97d6954c6f41 100644 --- a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsModule.cs +++ b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsModule.cs @@ -13,6 +13,7 @@ using Datadog.Trace.FeatureFlags.Agentless; using Datadog.Trace.FeatureFlags.Exposure; using Datadog.Trace.FeatureFlags.Exposure.Model; +using Datadog.Trace.FeatureFlags.FlagEvaluation; using Datadog.Trace.FeatureFlags.Rcm; using Datadog.Trace.FeatureFlags.Rcm.Model; using Datadog.Trace.Logging; @@ -52,6 +53,7 @@ internal sealed class FeatureFlagsModule : IDisposable private readonly bool _spanEnrichmentEnabled; private readonly IRcmSubscriptionManager _rcmSubscriptionManager; private readonly TaskCompletionSource _firstConfigReceived = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _disposeCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously); private ISubscription? _rcmSubscription; private Action? _onNewConfigEventHandler; @@ -65,6 +67,10 @@ internal sealed class FeatureFlagsModule : IDisposable private FeatureFlagsEvaluator? _evaluator; private IFeatureFlagsDeliverySource? _agentlessSource; private ExposureApi? _exposureApi; + private FlagEvaluationAgentSender? _evaluationSender; + private FlagEvaluationWriter? _evaluationWriter; + private IDisposable? _evaluationSettingsSubscription; + private IReadOnlyDictionary _evaluationContext = new Dictionary(); private string? _deliveryUnavailableReason; private bool _activated; private bool _disposed; @@ -102,6 +108,8 @@ internal FeatureFlagsModule( /// internal bool HasConfiguration => Volatile.Read(ref _evaluator) is not null; + internal FlagEvaluationWriter? EvaluationWriter => Volatile.Read(ref _evaluationWriter); + public static FeatureFlagsModule? Create( TracerSettings settings, IRcmSubscriptionManager rcmSubscriptionManager, @@ -124,17 +132,22 @@ internal FeatureFlagsModule( return module; } - public void Dispose() + public void Dispose() => _ = DisposeAsync(); + + internal Task DisposeAsync() { ISubscription? subscription; IFeatureFlagsDeliverySource? agentlessSource; ExposureApi? exposureApi; + FlagEvaluationWriter? writer; + FlagEvaluationAgentSender? sender; + IDisposable? settingsSubscription; lock (_stateLock) { if (_disposed) { - return; + return _disposeCompletion.Task; } _disposed = true; @@ -142,23 +155,26 @@ public void Dispose() subscription = _rcmSubscription; agentlessSource = _agentlessSource; exposureApi = _exposureApi; + writer = _evaluationWriter; + sender = _evaluationSender; + settingsSubscription = _evaluationSettingsSubscription; _rcmSubscription = null; _agentlessSource = null; Volatile.Write(ref _exposureApi, null); + Volatile.Write(ref _evaluationWriter, null); + _evaluationSender = null; + _evaluationSettingsSubscription = null; } - // Released the lock first: disposal is not state mutation, and holding it here would - // block an activation or an exposure for the duration. - if (subscription is not null) - { - _rcmSubscriptionManager.Unsubscribe(subscription); - } - - agentlessSource?.Dispose(); - exposureApi?.Dispose(); + // Close admission synchronously, then perform cleanup and bounded waiting outside the lock. + var close = writer?.CloseAsync(TimeSpan.FromSeconds(5)) ?? Task.CompletedTask; + _ = FinishDisposalAsync(close, sender, settingsSubscription, subscription, agentlessSource, exposureApi); + return _disposeCompletion.Task; } + internal Task FlushAsync() => EvaluationWriter?.FlushAsync() ?? Task.CompletedTask; + /// /// Signals that application code initialized the provider. Idempotent. /// @@ -187,7 +203,8 @@ internal void Activate() return; } - _activated = true; + StartEvaluationWriter(); + Volatile.Write(ref _activated, true); switch (_settings.Source) { @@ -347,6 +364,93 @@ internal bool ApplyConfiguration(ServerConfiguration configuration) return true; } + private void StartEvaluationWriter() + { + FlagEvaluationAgentSender? sender = null; + FlagEvaluationWriter? writer = null; + try + { + sender = new FlagEvaluationAgentSender(_settingsManager.InitialExporterSettings); + UpdateEvaluationContext(_settingsManager.InitialMutableSettings); + writer = new FlagEvaluationWriter(sender.SendCompressedAsync, () => Volatile.Read(ref _evaluationContext)); + var currentSender = sender; + _evaluationSettingsSubscription = _settingsManager.SubscribeToChanges(changes => + { + if (changes.UpdatedExporter is { } exporter) + { + currentSender.UpdateExporterSettings(exporter); + } + + if (changes.UpdatedMutable is { } mutable) + { + UpdateEvaluationContext(mutable); + } + }); + _evaluationSender = sender; + Volatile.Write(ref _evaluationWriter, writer); + } + catch (Exception) + { + // A telemetry setup failure must not prevent fetching flags or evaluating them. + _ = writer?.CloseAsync(TimeSpan.Zero); + sender?.Dispose(); + Log.Debug("FeatureFlags flagevaluation writer could not be started."); + } + } + + private void UpdateEvaluationContext(MutableSettings settings) + { + IReadOnlyDictionary context = new Dictionary + { + ["service"] = settings.DefaultServiceName, + ["env"] = settings.Environment ?? "unknown", + ["version"] = settings.ServiceVersion ?? "unknown", + }; + Volatile.Write(ref _evaluationContext, context); + } + + private async Task FinishDisposalAsync( + Task close, + FlagEvaluationAgentSender? sender, + IDisposable? settingsSubscription, + ISubscription? subscription, + IFeatureFlagsDeliverySource? agentlessSource, + ExposureApi? exposureApi) + { + try + { + settingsSubscription?.Dispose(); + if (subscription is not null) + { + _rcmSubscriptionManager.Unsubscribe(subscription); + } + + agentlessSource?.Dispose(); + exposureApi?.Dispose(); + } + catch (Exception) + { + Log.Debug("FeatureFlags cleanup failed; continuing bounded flagevaluation shutdown."); + } + finally + { + try + { + await close.ConfigureAwait(false); + } + catch (Exception) + { + Log.Debug("FeatureFlags flagevaluation shutdown failed."); + } + finally + { + // Dispose stops new sends but preserves any request still live after the deadline. + sender?.Dispose(); + _disposeCompletion.TrySetResult(true); + } + } + } + /// /// Hands one configuration event to the registered handler, whether a configuration arrived or /// was withdrawn. Claiming the event under the lock and calling the handler outside it keeps diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationTelemetry.cs b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationTelemetry.cs new file mode 100644 index 000000000000..81e9ecdfeeb4 --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationTelemetry.cs @@ -0,0 +1,116 @@ +// +// 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 Datadog.Trace.Telemetry; +using Datadog.Trace.Telemetry.Metrics; + +namespace Datadog.Trace.FeatureFlags.FlagEvaluation; + +internal sealed class FlagEvaluationTelemetry(IMetricsTelemetryCollector metrics) +{ + private readonly IMetricsTelemetryCollector _metrics = metrics; + + internal void Dropped(MetricTags.FlagEvaluationDropReason reason, long count = 1) + { + if (count <= 0) + { + return; + } + + try + { + _metrics.RecordCountFlagEvaluationRowsDropped(reason, Increment(count)); + } + catch (Exception) + { + // Telemetry is best-effort, never part of evaluation correctness. + } + } + + internal void Degraded(MetricTags.FlagEvaluationDegradeReason reason, long count) + { + if (count <= 0) + { + return; + } + + try + { + _metrics.RecordCountFlagEvaluationRowsDegraded(reason, Increment(count)); + } + catch (Exception) + { + } + } + + internal void InvalidTargetingKeys(long count) + { + if (count <= 0) + { + return; + } + + try + { + _metrics.RecordCountFlagEvaluationTargetingKeyOmitted(MetricTags.FlagEvaluationTargetingReason.Invalid, Increment(count)); + } + catch (Exception) + { + } + } + + internal void PayloadSplits(int count) + { + if (count <= 0) + { + return; + } + + try + { + _metrics.RecordCountFlagEvaluationPayloadSplits(count); + } + catch (Exception) + { + } + } + + internal void ContextOmissions(ContextOmissionReason reasons) + { + for (var bit = 0; bit <= (int)MetricTags.FlagEvaluationContextReason.SnapshotError; bit++) + { + if (((int)reasons & (1 << bit)) == 0) + { + continue; + } + + try + { + _metrics.RecordCountFlagEvaluationContextTruncated((MetricTags.FlagEvaluationContextReason)bit); + } + catch (Exception) + { + } + } + } + + internal void HookError() + { + try + { + _metrics.RecordCountFlagEvaluationHookErrors(); + } + catch (Exception) + { + } + } + + // The shared collector accepts int increments. Keep best-effort metrics nonnegative + // for exceptionally large batches without changing long-valued payload counts. + private static int Increment(long count) => (int)Math.Min(count, int.MaxValue); +} diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationWriter.cs b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationWriter.cs new file mode 100644 index 000000000000..358892fb676a --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationWriter.cs @@ -0,0 +1,330 @@ +// +// 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 System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; +using Datadog.Trace.Logging; +using Datadog.Trace.Telemetry; +using Datadog.Trace.Telemetry.Metrics; +using Datadog.Trace.Util; + +namespace Datadog.Trace.FeatureFlags.FlagEvaluation; + +internal sealed class FlagEvaluationWriter +{ + private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(); + private readonly object _gate = new(); + private readonly AutoResetEvent _wake = new(false); + private readonly BoundedConcurrentQueue _queue; + private readonly FlagEvaluationAggregator _aggregator; + private readonly FlagEvaluationTelemetry _telemetry; + private readonly Func, Task> _send; + private readonly Func> _getContext; + private readonly int _queueCap; + private readonly int _payloadLimitBytes; + private readonly TimeSpan _flushInterval; + private readonly Task _consumer; + private TaskCompletionSource? _flush; + private bool _closed; + private bool _abandon; + private long _accepted; + private long _processed; + + internal FlagEvaluationWriter( + Func, Task> send, + Func> getContext, + int queueCap = 16384, + int globalCap = 131072, + int perFlagCap = 10000, + int degradedCap = 32768, + int payloadLimitBytes = 5 * 1024 * 1024, + TimeSpan? flushInterval = null, + IMetricsTelemetryCollector? metrics = null) + { + _send = send; + _getContext = getContext; + _queueCap = queueCap; + _payloadLimitBytes = payloadLimitBytes; + _flushInterval = flushInterval ?? TimeSpan.FromSeconds(10); + _queue = new BoundedConcurrentQueue(queueCap); + _aggregator = new FlagEvaluationAggregator(globalCap, perFlagCap, degradedCap); + _telemetry = new FlagEvaluationTelemetry(metrics ?? TelemetryFactory.Metrics); + _consumer = Task.Factory.StartNew(ProcessLoop, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + + internal bool HasCapacity() + { + if (Volatile.Read(ref _closed)) + { + _telemetry.Dropped(MetricTags.FlagEvaluationDropReason.Closed); + return false; + } + + if (_queue.Count >= _queueCap) + { + _telemetry.Dropped(MetricTags.FlagEvaluationDropReason.PreQueueOverflow); + return false; + } + + return true; + } + + internal bool TryEnqueue(FlagEvalEvent observation, int omissionReasons = 0) + { + // Provider and defensive event normalization may report the same reason. + // Record the union before admission: a rejected event will never reach the consumer. + _telemetry.ContextOmissions(observation.ContextOmissions | (ContextOmissionReason)omissionReasons); + // Only admission and control signals use this lock: never snapshot, aggregate, or send. + lock (_gate) + { + if (_closed) + { + _telemetry.Dropped(MetricTags.FlagEvaluationDropReason.Closed); + return false; + } + + if (!_queue.TryEnqueue(observation)) + { + _telemetry.Dropped(MetricTags.FlagEvaluationDropReason.QueueOverflow); + return false; + } + + _accepted++; + _wake.Set(); + return true; + } + } + + internal Task FlushAsync() + { + lock (_gate) + { + if (_closed) + { + return Task.CompletedTask; + } + + // Coalesce callers rather than queueing an unbounded list of control messages. + _flush ??= new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _wake.Set(); + return _flush.Task; + } + } + + internal async Task CloseAsync(TimeSpan timeout) + { + lock (_gate) + { + if (!_closed) + { + Volatile.Write(ref _closed, true); + _wake.Set(); + } + } + + if (await Task.WhenAny(_consumer, Task.Delay(timeout)).ConfigureAwait(false) != _consumer) + { + // Do not race the sole queue consumer or dispose its wake handle while a send is live. + Volatile.Write(ref _abandon, true); + return; + } + + await _consumer.ConfigureAwait(false); + } + + private static long EvaluationCount(DrainResult state) + { + long count = 0; + foreach (var entry in state.Full.Values) + { + count += entry.Count; + } + + foreach (var entry in state.Degraded.Values) + { + count += entry.Count; + } + + return count; + } + + private void ProcessLoop() + { + var sinceFlush = Stopwatch.StartNew(); + try + { + while (!Volatile.Read(ref _abandon)) + { + // Continuous producers must not starve timer, explicit flush, or close checks. + Drain(256); + TaskCompletionSource? completion; + long target; + bool closing; + lock (_gate) + { + closing = _closed; + var flushDue = closing || _flush is not null || sinceFlush.Elapsed >= _flushInterval; + completion = flushDue ? _flush : null; + target = flushDue ? _accepted : -1; + if (flushDue) + { + _flush = null; + } + } + + if (target >= 0) + { + try + { + // The target freezes admission for this flush, not intake itself. + // Anything accepted before the control check is published in the queue. + while (_processed < target && !Volatile.Read(ref _abandon)) + { + Drain((int)Math.Min(256, target - _processed)); + } + + if (!Volatile.Read(ref _abandon)) + { + SendSnapshot(); + } + } + catch (Exception) + { + Log.Debug("FeatureFlags flagevaluation flush failed; discarding this batch without exposing diagnostics."); + } + finally + { + completion?.TrySetResult(true); + } + + sinceFlush.Restart(); + if (closing) + { + break; + } + } + + var remaining = _flushInterval - sinceFlush.Elapsed; + if (_queue.IsEmpty && remaining > TimeSpan.Zero) + { + _wake.WaitOne(remaining); + } + } + } + catch (Exception) + { + Log.Debug("FeatureFlags flagevaluation background consumer stopped unexpectedly."); + } + finally + { + lock (_gate) + { + Volatile.Write(ref _closed, true); + _flush?.TrySetResult(true); + _flush = null; + _wake.Dispose(); + } + + long discarded = 0; + while (_queue.TryDequeue(out _)) + { + discarded++; + } + + var remaining = _aggregator.Drain(); + RecordAggregationMetrics(remaining); + _telemetry.Dropped(MetricTags.FlagEvaluationDropReason.Closed, discarded + EvaluationCount(remaining)); + } + } + + private void Drain(int limit) + { + // Keep the last observation in this short-lived frame, not the idle consumer loop. + for (var i = 0; i < limit && _queue.TryDequeue(out var observation); i++) + { + _processed++; + try + { + _aggregator.Add(observation); + } + catch (Exception) + { + _telemetry.Dropped(MetricTags.FlagEvaluationDropReason.SerializationError); + Log.Debug("FeatureFlags flagevaluation observation could not be aggregated."); + } + } + } + + private void SendSnapshot() + { + // Release the drained maps and their customer data before waiting on network I/O. + var result = EncodeSnapshot(); + foreach (var payload in result.Payloads) + { + if (Volatile.Read(ref _abandon)) + { + break; + } + + using var stream = new MemoryStream(); + using (var gzip = new GZipStream(stream, CompressionLevel.Fastest, leaveOpen: true)) + { + gzip.Write(payload, 0, payload.Length); + } + + try + { + // The dedicated consumer owns all CPU and network work; evaluation never waits here. + _send(new ArraySegment(stream.ToArray())).GetAwaiter().GetResult(); + } + catch (Exception) + { + Log.Debug("FeatureFlags flagevaluation send failed; dropping this batch without retry."); + } + } + } + + private FlagEvaluationPayloadResult EncodeSnapshot() + { + var state = _aggregator.Drain(); + RecordAggregationMetrics(state); + try + { + var result = state.Full.Count == 0 && state.Degraded.Count == 0 + ? new FlagEvaluationPayloadResult(Array.Empty(), 0, 0, 0) + : FlagEvaluationPayload.Encode(state, _getContext(), DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), _payloadLimitBytes); + _telemetry.Degraded(MetricTags.FlagEvaluationDegradeReason.PayloadLimit, result.PayloadDegradedEvaluations); + _telemetry.Dropped(MetricTags.FlagEvaluationDropReason.PayloadLimit, result.PayloadDroppedEvaluations); + _telemetry.Dropped(MetricTags.FlagEvaluationDropReason.SerializationError, result.SerializationDroppedEvaluations); + _telemetry.PayloadSplits(result.Payloads.Count - 1); + return result; + } + catch (Exception) + { + _telemetry.Dropped(MetricTags.FlagEvaluationDropReason.SerializationError, EvaluationCount(state)); + throw; + } + } + + private void RecordAggregationMetrics(DrainResult state) + { + _telemetry.Dropped(MetricTags.FlagEvaluationDropReason.DegradedCap, state.Dropped); + _telemetry.InvalidTargetingKeys(state.InvalidTargetingKeys); + long degraded = 0; + foreach (var entry in state.Degraded.Values) + { + degraded += entry.Count; + } + + _telemetry.Degraded(MetricTags.FlagEvaluationDegradeReason.CardinalityCap, degraded); + } +} diff --git a/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CiVisibilityMetricsTelemetryCollector_Count.g.cs b/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CiVisibilityMetricsTelemetryCollector_Count.g.cs index e368254a25e3..b2c11e41e5b5 100644 --- a/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CiVisibilityMetricsTelemetryCollector_Count.g.cs +++ b/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CiVisibilityMetricsTelemetryCollector_Count.g.cs @@ -263,4 +263,28 @@ public void RecordCountIastRequestTainted(int increment = 1) public void RecordCountIastSuppressedVulnerabilities(Datadog.Trace.Telemetry.Metrics.MetricTags.IastVulnerabilityType tag, int increment = 1) { } + + public void RecordCountFlagEvaluationRowsDropped(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDropReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationRowsDegraded(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDegradeReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationPayloadSplits(int increment = 1) + { + } + + public void RecordCountFlagEvaluationContextTruncated(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationContextReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationTargetingKeyOmitted(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationTargetingReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationHookErrors(int increment = 1) + { + } } \ No newline at end of file diff --git a/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CountExtensions.g.cs b/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CountExtensions.g.cs index 8a0a04e4e5e1..a4c1ffe1b0cd 100644 --- a/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CountExtensions.g.cs +++ b/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CountExtensions.g.cs @@ -12,7 +12,7 @@ internal static partial class CountExtensions /// /// The number of separate metrics in the metric. /// - public const int Length = 63; + public const int Length = 69; /// /// Gets the metric name for the provided metric @@ -85,6 +85,12 @@ public static string GetName(this Datadog.Trace.Telemetry.Metrics.Count metric) Datadog.Trace.Telemetry.Metrics.Count.IastExecutedSinks => "executed.sink", Datadog.Trace.Telemetry.Metrics.Count.IastRequestTainted => "request.tainted", Datadog.Trace.Telemetry.Metrics.Count.IastSuppressedVulnerabilities => "suppressed.vulnerabilities", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationRowsDropped => "flagevaluation.rows.dropped", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationRowsDegraded => "flagevaluation.rows.degraded", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationPayloadSplits => "flagevaluation.payload.splits", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationContextTruncated => "flagevaluation.context.truncated", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationTargetingKeyOmitted => "flagevaluation.targeting_key.omitted", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationHookErrors => "flagevaluation.hook.errors", _ => null!, }; diff --git a/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/IMetricsTelemetryCollector_Count.g.cs b/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/IMetricsTelemetryCollector_Count.g.cs index d397e0881363..3c9eb975656a 100644 --- a/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/IMetricsTelemetryCollector_Count.g.cs +++ b/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/IMetricsTelemetryCollector_Count.g.cs @@ -134,4 +134,16 @@ internal partial interface IMetricsTelemetryCollector public void RecordCountIastRequestTainted(int increment = 1); public void RecordCountIastSuppressedVulnerabilities(Datadog.Trace.Telemetry.Metrics.MetricTags.IastVulnerabilityType tag, int increment = 1); + + public void RecordCountFlagEvaluationRowsDropped(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDropReason tag, int increment = 1); + + public void RecordCountFlagEvaluationRowsDegraded(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDegradeReason tag, int increment = 1); + + public void RecordCountFlagEvaluationPayloadSplits(int increment = 1); + + public void RecordCountFlagEvaluationContextTruncated(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationContextReason tag, int increment = 1); + + public void RecordCountFlagEvaluationTargetingKeyOmitted(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationTargetingReason tag, int increment = 1); + + public void RecordCountFlagEvaluationHookErrors(int increment = 1); } \ No newline at end of file diff --git a/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/MetricsTelemetryCollector_Count.g.cs b/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/MetricsTelemetryCollector_Count.g.cs index 5593f1cb4e54..bc109b9773d4 100644 --- a/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/MetricsTelemetryCollector_Count.g.cs +++ b/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/MetricsTelemetryCollector_Count.g.cs @@ -11,7 +11,7 @@ namespace Datadog.Trace.Telemetry; internal sealed partial class MetricsTelemetryCollector { - private const int CountLength = 800; + private const int CountLength = 820; /// /// Creates the buffer for the values. @@ -882,6 +882,32 @@ private static AggregatedMetric[] GetCountBuffer() new(new[] { "vulnerability_type:directory_listing_leak" }), new(new[] { "vulnerability_type:session_timeout" }), new(new[] { "vulnerability_type:email_html_injection" }), + // flagevaluation.rows.dropped, index = 800 + new(new[] { "reason:pre_queue_overflow" }), + new(new[] { "reason:queue_overflow" }), + new(new[] { "reason:closed" }), + new(new[] { "reason:degraded_cap" }), + new(new[] { "reason:payload_limit" }), + new(new[] { "reason:serialization_error" }), + // flagevaluation.rows.degraded, index = 806 + new(new[] { "reason:cardinality_cap" }), + new(new[] { "reason:payload_limit" }), + // flagevaluation.payload.splits, index = 808 + new(null), + // flagevaluation.context.truncated, index = 809 + new(new[] { "reason:max_context_fields" }), + new(new[] { "reason:max_key_length" }), + new(new[] { "reason:max_value_length" }), + new(new[] { "reason:max_list_elements" }), + new(new[] { "reason:max_structure_properties" }), + new(new[] { "reason:max_snapshot_depth" }), + new(new[] { "reason:max_visited_nodes" }), + new(new[] { "reason:unsupported_value" }), + new(new[] { "reason:snapshot_error" }), + // flagevaluation.targeting_key.omitted, index = 818 + new(new[] { "reason:invalid" }), + // flagevaluation.hook.errors, index = 819 + new(null), }; /// @@ -890,7 +916,7 @@ private static AggregatedMetric[] GetCountBuffer() /// It is equal to the cardinality of the tag combinations (or 1 if there are no tags) /// private static int[] CountEntryCounts { get; } - = new int[]{ 4, 85, 1, 3, 7, 2, 2, 7, 1, 1, 1, 22, 3, 2, 5, 5, 4, 1, 1, 22, 3, 34, 90, 90, 4, 4, 4, 4, 2, 44, 6, 1, 1, 85, 1, 22, 3, 8, 2, 10, 8, 4, 12, 4, 16, 2, 2, 10, 3, 4, 5, 15, 5, 20, 10, 4, 4, 5, 14, 1, 27, 1, 27, }; + = new int[]{ 4, 85, 1, 3, 7, 2, 2, 7, 1, 1, 1, 22, 3, 2, 5, 5, 4, 1, 1, 22, 3, 34, 90, 90, 4, 4, 4, 4, 2, 44, 6, 1, 1, 85, 1, 22, 3, 8, 2, 10, 8, 4, 12, 4, 16, 2, 2, 10, 3, 4, 5, 15, 5, 20, 10, 4, 4, 5, 14, 1, 27, 1, 27, 6, 2, 1, 9, 1, 1, }; public void RecordCountLogCreated(Datadog.Trace.Telemetry.Metrics.MetricTags.LogLevel tag, int increment = 1) { @@ -1259,4 +1285,38 @@ public void RecordCountIastSuppressedVulnerabilities(Datadog.Trace.Telemetry.Met var index = 773 + (int)tag; Interlocked.Add(ref _buffer.Count[index], increment); } + + public void RecordCountFlagEvaluationRowsDropped(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDropReason tag, int increment = 1) + { + var index = 800 + (int)tag; + Interlocked.Add(ref _buffer.Count[index], increment); + } + + public void RecordCountFlagEvaluationRowsDegraded(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDegradeReason tag, int increment = 1) + { + var index = 806 + (int)tag; + Interlocked.Add(ref _buffer.Count[index], increment); + } + + public void RecordCountFlagEvaluationPayloadSplits(int increment = 1) + { + Interlocked.Add(ref _buffer.Count[808], increment); + } + + public void RecordCountFlagEvaluationContextTruncated(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationContextReason tag, int increment = 1) + { + var index = 809 + (int)tag; + Interlocked.Add(ref _buffer.Count[index], increment); + } + + public void RecordCountFlagEvaluationTargetingKeyOmitted(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationTargetingReason tag, int increment = 1) + { + var index = 818 + (int)tag; + Interlocked.Add(ref _buffer.Count[index], increment); + } + + public void RecordCountFlagEvaluationHookErrors(int increment = 1) + { + Interlocked.Add(ref _buffer.Count[819], increment); + } } \ No newline at end of file diff --git a/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/NullMetricsTelemetryCollector_Count.g.cs b/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/NullMetricsTelemetryCollector_Count.g.cs index 7643b4aee31f..446d2c394222 100644 --- a/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/NullMetricsTelemetryCollector_Count.g.cs +++ b/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/NullMetricsTelemetryCollector_Count.g.cs @@ -263,4 +263,28 @@ public void RecordCountIastRequestTainted(int increment = 1) public void RecordCountIastSuppressedVulnerabilities(Datadog.Trace.Telemetry.Metrics.MetricTags.IastVulnerabilityType tag, int increment = 1) { } + + public void RecordCountFlagEvaluationRowsDropped(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDropReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationRowsDegraded(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDegradeReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationPayloadSplits(int increment = 1) + { + } + + public void RecordCountFlagEvaluationContextTruncated(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationContextReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationTargetingKeyOmitted(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationTargetingReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationHookErrors(int increment = 1) + { + } } \ No newline at end of file diff --git a/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CiVisibilityMetricsTelemetryCollector_Count.g.cs b/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CiVisibilityMetricsTelemetryCollector_Count.g.cs index e368254a25e3..b2c11e41e5b5 100644 --- a/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CiVisibilityMetricsTelemetryCollector_Count.g.cs +++ b/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CiVisibilityMetricsTelemetryCollector_Count.g.cs @@ -263,4 +263,28 @@ public void RecordCountIastRequestTainted(int increment = 1) public void RecordCountIastSuppressedVulnerabilities(Datadog.Trace.Telemetry.Metrics.MetricTags.IastVulnerabilityType tag, int increment = 1) { } + + public void RecordCountFlagEvaluationRowsDropped(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDropReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationRowsDegraded(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDegradeReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationPayloadSplits(int increment = 1) + { + } + + public void RecordCountFlagEvaluationContextTruncated(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationContextReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationTargetingKeyOmitted(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationTargetingReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationHookErrors(int increment = 1) + { + } } \ No newline at end of file diff --git a/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CountExtensions.g.cs b/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CountExtensions.g.cs index 8a0a04e4e5e1..a4c1ffe1b0cd 100644 --- a/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CountExtensions.g.cs +++ b/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CountExtensions.g.cs @@ -12,7 +12,7 @@ internal static partial class CountExtensions /// /// The number of separate metrics in the metric. /// - public const int Length = 63; + public const int Length = 69; /// /// Gets the metric name for the provided metric @@ -85,6 +85,12 @@ public static string GetName(this Datadog.Trace.Telemetry.Metrics.Count metric) Datadog.Trace.Telemetry.Metrics.Count.IastExecutedSinks => "executed.sink", Datadog.Trace.Telemetry.Metrics.Count.IastRequestTainted => "request.tainted", Datadog.Trace.Telemetry.Metrics.Count.IastSuppressedVulnerabilities => "suppressed.vulnerabilities", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationRowsDropped => "flagevaluation.rows.dropped", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationRowsDegraded => "flagevaluation.rows.degraded", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationPayloadSplits => "flagevaluation.payload.splits", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationContextTruncated => "flagevaluation.context.truncated", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationTargetingKeyOmitted => "flagevaluation.targeting_key.omitted", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationHookErrors => "flagevaluation.hook.errors", _ => null!, }; diff --git a/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/IMetricsTelemetryCollector_Count.g.cs b/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/IMetricsTelemetryCollector_Count.g.cs index d397e0881363..3c9eb975656a 100644 --- a/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/IMetricsTelemetryCollector_Count.g.cs +++ b/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/IMetricsTelemetryCollector_Count.g.cs @@ -134,4 +134,16 @@ internal partial interface IMetricsTelemetryCollector public void RecordCountIastRequestTainted(int increment = 1); public void RecordCountIastSuppressedVulnerabilities(Datadog.Trace.Telemetry.Metrics.MetricTags.IastVulnerabilityType tag, int increment = 1); + + public void RecordCountFlagEvaluationRowsDropped(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDropReason tag, int increment = 1); + + public void RecordCountFlagEvaluationRowsDegraded(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDegradeReason tag, int increment = 1); + + public void RecordCountFlagEvaluationPayloadSplits(int increment = 1); + + public void RecordCountFlagEvaluationContextTruncated(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationContextReason tag, int increment = 1); + + public void RecordCountFlagEvaluationTargetingKeyOmitted(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationTargetingReason tag, int increment = 1); + + public void RecordCountFlagEvaluationHookErrors(int increment = 1); } \ No newline at end of file diff --git a/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/MetricsTelemetryCollector_Count.g.cs b/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/MetricsTelemetryCollector_Count.g.cs index 5593f1cb4e54..bc109b9773d4 100644 --- a/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/MetricsTelemetryCollector_Count.g.cs +++ b/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/MetricsTelemetryCollector_Count.g.cs @@ -11,7 +11,7 @@ namespace Datadog.Trace.Telemetry; internal sealed partial class MetricsTelemetryCollector { - private const int CountLength = 800; + private const int CountLength = 820; /// /// Creates the buffer for the values. @@ -882,6 +882,32 @@ private static AggregatedMetric[] GetCountBuffer() new(new[] { "vulnerability_type:directory_listing_leak" }), new(new[] { "vulnerability_type:session_timeout" }), new(new[] { "vulnerability_type:email_html_injection" }), + // flagevaluation.rows.dropped, index = 800 + new(new[] { "reason:pre_queue_overflow" }), + new(new[] { "reason:queue_overflow" }), + new(new[] { "reason:closed" }), + new(new[] { "reason:degraded_cap" }), + new(new[] { "reason:payload_limit" }), + new(new[] { "reason:serialization_error" }), + // flagevaluation.rows.degraded, index = 806 + new(new[] { "reason:cardinality_cap" }), + new(new[] { "reason:payload_limit" }), + // flagevaluation.payload.splits, index = 808 + new(null), + // flagevaluation.context.truncated, index = 809 + new(new[] { "reason:max_context_fields" }), + new(new[] { "reason:max_key_length" }), + new(new[] { "reason:max_value_length" }), + new(new[] { "reason:max_list_elements" }), + new(new[] { "reason:max_structure_properties" }), + new(new[] { "reason:max_snapshot_depth" }), + new(new[] { "reason:max_visited_nodes" }), + new(new[] { "reason:unsupported_value" }), + new(new[] { "reason:snapshot_error" }), + // flagevaluation.targeting_key.omitted, index = 818 + new(new[] { "reason:invalid" }), + // flagevaluation.hook.errors, index = 819 + new(null), }; /// @@ -890,7 +916,7 @@ private static AggregatedMetric[] GetCountBuffer() /// It is equal to the cardinality of the tag combinations (or 1 if there are no tags) /// private static int[] CountEntryCounts { get; } - = new int[]{ 4, 85, 1, 3, 7, 2, 2, 7, 1, 1, 1, 22, 3, 2, 5, 5, 4, 1, 1, 22, 3, 34, 90, 90, 4, 4, 4, 4, 2, 44, 6, 1, 1, 85, 1, 22, 3, 8, 2, 10, 8, 4, 12, 4, 16, 2, 2, 10, 3, 4, 5, 15, 5, 20, 10, 4, 4, 5, 14, 1, 27, 1, 27, }; + = new int[]{ 4, 85, 1, 3, 7, 2, 2, 7, 1, 1, 1, 22, 3, 2, 5, 5, 4, 1, 1, 22, 3, 34, 90, 90, 4, 4, 4, 4, 2, 44, 6, 1, 1, 85, 1, 22, 3, 8, 2, 10, 8, 4, 12, 4, 16, 2, 2, 10, 3, 4, 5, 15, 5, 20, 10, 4, 4, 5, 14, 1, 27, 1, 27, 6, 2, 1, 9, 1, 1, }; public void RecordCountLogCreated(Datadog.Trace.Telemetry.Metrics.MetricTags.LogLevel tag, int increment = 1) { @@ -1259,4 +1285,38 @@ public void RecordCountIastSuppressedVulnerabilities(Datadog.Trace.Telemetry.Met var index = 773 + (int)tag; Interlocked.Add(ref _buffer.Count[index], increment); } + + public void RecordCountFlagEvaluationRowsDropped(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDropReason tag, int increment = 1) + { + var index = 800 + (int)tag; + Interlocked.Add(ref _buffer.Count[index], increment); + } + + public void RecordCountFlagEvaluationRowsDegraded(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDegradeReason tag, int increment = 1) + { + var index = 806 + (int)tag; + Interlocked.Add(ref _buffer.Count[index], increment); + } + + public void RecordCountFlagEvaluationPayloadSplits(int increment = 1) + { + Interlocked.Add(ref _buffer.Count[808], increment); + } + + public void RecordCountFlagEvaluationContextTruncated(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationContextReason tag, int increment = 1) + { + var index = 809 + (int)tag; + Interlocked.Add(ref _buffer.Count[index], increment); + } + + public void RecordCountFlagEvaluationTargetingKeyOmitted(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationTargetingReason tag, int increment = 1) + { + var index = 818 + (int)tag; + Interlocked.Add(ref _buffer.Count[index], increment); + } + + public void RecordCountFlagEvaluationHookErrors(int increment = 1) + { + Interlocked.Add(ref _buffer.Count[819], increment); + } } \ No newline at end of file diff --git a/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/NullMetricsTelemetryCollector_Count.g.cs b/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/NullMetricsTelemetryCollector_Count.g.cs index 7643b4aee31f..446d2c394222 100644 --- a/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/NullMetricsTelemetryCollector_Count.g.cs +++ b/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/NullMetricsTelemetryCollector_Count.g.cs @@ -263,4 +263,28 @@ public void RecordCountIastRequestTainted(int increment = 1) public void RecordCountIastSuppressedVulnerabilities(Datadog.Trace.Telemetry.Metrics.MetricTags.IastVulnerabilityType tag, int increment = 1) { } + + public void RecordCountFlagEvaluationRowsDropped(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDropReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationRowsDegraded(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDegradeReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationPayloadSplits(int increment = 1) + { + } + + public void RecordCountFlagEvaluationContextTruncated(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationContextReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationTargetingKeyOmitted(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationTargetingReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationHookErrors(int increment = 1) + { + } } \ No newline at end of file diff --git a/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CiVisibilityMetricsTelemetryCollector_Count.g.cs b/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CiVisibilityMetricsTelemetryCollector_Count.g.cs index e368254a25e3..b2c11e41e5b5 100644 --- a/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CiVisibilityMetricsTelemetryCollector_Count.g.cs +++ b/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CiVisibilityMetricsTelemetryCollector_Count.g.cs @@ -263,4 +263,28 @@ public void RecordCountIastRequestTainted(int increment = 1) public void RecordCountIastSuppressedVulnerabilities(Datadog.Trace.Telemetry.Metrics.MetricTags.IastVulnerabilityType tag, int increment = 1) { } + + public void RecordCountFlagEvaluationRowsDropped(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDropReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationRowsDegraded(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDegradeReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationPayloadSplits(int increment = 1) + { + } + + public void RecordCountFlagEvaluationContextTruncated(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationContextReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationTargetingKeyOmitted(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationTargetingReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationHookErrors(int increment = 1) + { + } } \ No newline at end of file diff --git a/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CountExtensions.g.cs b/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CountExtensions.g.cs index 8a0a04e4e5e1..a4c1ffe1b0cd 100644 --- a/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CountExtensions.g.cs +++ b/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CountExtensions.g.cs @@ -12,7 +12,7 @@ internal static partial class CountExtensions /// /// The number of separate metrics in the metric. /// - public const int Length = 63; + public const int Length = 69; /// /// Gets the metric name for the provided metric @@ -85,6 +85,12 @@ public static string GetName(this Datadog.Trace.Telemetry.Metrics.Count metric) Datadog.Trace.Telemetry.Metrics.Count.IastExecutedSinks => "executed.sink", Datadog.Trace.Telemetry.Metrics.Count.IastRequestTainted => "request.tainted", Datadog.Trace.Telemetry.Metrics.Count.IastSuppressedVulnerabilities => "suppressed.vulnerabilities", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationRowsDropped => "flagevaluation.rows.dropped", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationRowsDegraded => "flagevaluation.rows.degraded", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationPayloadSplits => "flagevaluation.payload.splits", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationContextTruncated => "flagevaluation.context.truncated", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationTargetingKeyOmitted => "flagevaluation.targeting_key.omitted", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationHookErrors => "flagevaluation.hook.errors", _ => null!, }; diff --git a/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/IMetricsTelemetryCollector_Count.g.cs b/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/IMetricsTelemetryCollector_Count.g.cs index d397e0881363..3c9eb975656a 100644 --- a/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/IMetricsTelemetryCollector_Count.g.cs +++ b/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/IMetricsTelemetryCollector_Count.g.cs @@ -134,4 +134,16 @@ internal partial interface IMetricsTelemetryCollector public void RecordCountIastRequestTainted(int increment = 1); public void RecordCountIastSuppressedVulnerabilities(Datadog.Trace.Telemetry.Metrics.MetricTags.IastVulnerabilityType tag, int increment = 1); + + public void RecordCountFlagEvaluationRowsDropped(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDropReason tag, int increment = 1); + + public void RecordCountFlagEvaluationRowsDegraded(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDegradeReason tag, int increment = 1); + + public void RecordCountFlagEvaluationPayloadSplits(int increment = 1); + + public void RecordCountFlagEvaluationContextTruncated(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationContextReason tag, int increment = 1); + + public void RecordCountFlagEvaluationTargetingKeyOmitted(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationTargetingReason tag, int increment = 1); + + public void RecordCountFlagEvaluationHookErrors(int increment = 1); } \ No newline at end of file diff --git a/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/MetricsTelemetryCollector_Count.g.cs b/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/MetricsTelemetryCollector_Count.g.cs index 5593f1cb4e54..bc109b9773d4 100644 --- a/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/MetricsTelemetryCollector_Count.g.cs +++ b/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/MetricsTelemetryCollector_Count.g.cs @@ -11,7 +11,7 @@ namespace Datadog.Trace.Telemetry; internal sealed partial class MetricsTelemetryCollector { - private const int CountLength = 800; + private const int CountLength = 820; /// /// Creates the buffer for the values. @@ -882,6 +882,32 @@ private static AggregatedMetric[] GetCountBuffer() new(new[] { "vulnerability_type:directory_listing_leak" }), new(new[] { "vulnerability_type:session_timeout" }), new(new[] { "vulnerability_type:email_html_injection" }), + // flagevaluation.rows.dropped, index = 800 + new(new[] { "reason:pre_queue_overflow" }), + new(new[] { "reason:queue_overflow" }), + new(new[] { "reason:closed" }), + new(new[] { "reason:degraded_cap" }), + new(new[] { "reason:payload_limit" }), + new(new[] { "reason:serialization_error" }), + // flagevaluation.rows.degraded, index = 806 + new(new[] { "reason:cardinality_cap" }), + new(new[] { "reason:payload_limit" }), + // flagevaluation.payload.splits, index = 808 + new(null), + // flagevaluation.context.truncated, index = 809 + new(new[] { "reason:max_context_fields" }), + new(new[] { "reason:max_key_length" }), + new(new[] { "reason:max_value_length" }), + new(new[] { "reason:max_list_elements" }), + new(new[] { "reason:max_structure_properties" }), + new(new[] { "reason:max_snapshot_depth" }), + new(new[] { "reason:max_visited_nodes" }), + new(new[] { "reason:unsupported_value" }), + new(new[] { "reason:snapshot_error" }), + // flagevaluation.targeting_key.omitted, index = 818 + new(new[] { "reason:invalid" }), + // flagevaluation.hook.errors, index = 819 + new(null), }; /// @@ -890,7 +916,7 @@ private static AggregatedMetric[] GetCountBuffer() /// It is equal to the cardinality of the tag combinations (or 1 if there are no tags) /// private static int[] CountEntryCounts { get; } - = new int[]{ 4, 85, 1, 3, 7, 2, 2, 7, 1, 1, 1, 22, 3, 2, 5, 5, 4, 1, 1, 22, 3, 34, 90, 90, 4, 4, 4, 4, 2, 44, 6, 1, 1, 85, 1, 22, 3, 8, 2, 10, 8, 4, 12, 4, 16, 2, 2, 10, 3, 4, 5, 15, 5, 20, 10, 4, 4, 5, 14, 1, 27, 1, 27, }; + = new int[]{ 4, 85, 1, 3, 7, 2, 2, 7, 1, 1, 1, 22, 3, 2, 5, 5, 4, 1, 1, 22, 3, 34, 90, 90, 4, 4, 4, 4, 2, 44, 6, 1, 1, 85, 1, 22, 3, 8, 2, 10, 8, 4, 12, 4, 16, 2, 2, 10, 3, 4, 5, 15, 5, 20, 10, 4, 4, 5, 14, 1, 27, 1, 27, 6, 2, 1, 9, 1, 1, }; public void RecordCountLogCreated(Datadog.Trace.Telemetry.Metrics.MetricTags.LogLevel tag, int increment = 1) { @@ -1259,4 +1285,38 @@ public void RecordCountIastSuppressedVulnerabilities(Datadog.Trace.Telemetry.Met var index = 773 + (int)tag; Interlocked.Add(ref _buffer.Count[index], increment); } + + public void RecordCountFlagEvaluationRowsDropped(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDropReason tag, int increment = 1) + { + var index = 800 + (int)tag; + Interlocked.Add(ref _buffer.Count[index], increment); + } + + public void RecordCountFlagEvaluationRowsDegraded(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDegradeReason tag, int increment = 1) + { + var index = 806 + (int)tag; + Interlocked.Add(ref _buffer.Count[index], increment); + } + + public void RecordCountFlagEvaluationPayloadSplits(int increment = 1) + { + Interlocked.Add(ref _buffer.Count[808], increment); + } + + public void RecordCountFlagEvaluationContextTruncated(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationContextReason tag, int increment = 1) + { + var index = 809 + (int)tag; + Interlocked.Add(ref _buffer.Count[index], increment); + } + + public void RecordCountFlagEvaluationTargetingKeyOmitted(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationTargetingReason tag, int increment = 1) + { + var index = 818 + (int)tag; + Interlocked.Add(ref _buffer.Count[index], increment); + } + + public void RecordCountFlagEvaluationHookErrors(int increment = 1) + { + Interlocked.Add(ref _buffer.Count[819], increment); + } } \ No newline at end of file diff --git a/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/NullMetricsTelemetryCollector_Count.g.cs b/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/NullMetricsTelemetryCollector_Count.g.cs index 7643b4aee31f..446d2c394222 100644 --- a/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/NullMetricsTelemetryCollector_Count.g.cs +++ b/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/NullMetricsTelemetryCollector_Count.g.cs @@ -263,4 +263,28 @@ public void RecordCountIastRequestTainted(int increment = 1) public void RecordCountIastSuppressedVulnerabilities(Datadog.Trace.Telemetry.Metrics.MetricTags.IastVulnerabilityType tag, int increment = 1) { } + + public void RecordCountFlagEvaluationRowsDropped(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDropReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationRowsDegraded(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDegradeReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationPayloadSplits(int increment = 1) + { + } + + public void RecordCountFlagEvaluationContextTruncated(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationContextReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationTargetingKeyOmitted(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationTargetingReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationHookErrors(int increment = 1) + { + } } \ No newline at end of file diff --git a/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CiVisibilityMetricsTelemetryCollector_Count.g.cs b/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CiVisibilityMetricsTelemetryCollector_Count.g.cs index e368254a25e3..b2c11e41e5b5 100644 --- a/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CiVisibilityMetricsTelemetryCollector_Count.g.cs +++ b/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CiVisibilityMetricsTelemetryCollector_Count.g.cs @@ -263,4 +263,28 @@ public void RecordCountIastRequestTainted(int increment = 1) public void RecordCountIastSuppressedVulnerabilities(Datadog.Trace.Telemetry.Metrics.MetricTags.IastVulnerabilityType tag, int increment = 1) { } + + public void RecordCountFlagEvaluationRowsDropped(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDropReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationRowsDegraded(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDegradeReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationPayloadSplits(int increment = 1) + { + } + + public void RecordCountFlagEvaluationContextTruncated(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationContextReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationTargetingKeyOmitted(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationTargetingReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationHookErrors(int increment = 1) + { + } } \ No newline at end of file diff --git a/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CountExtensions.g.cs b/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CountExtensions.g.cs index 8a0a04e4e5e1..a4c1ffe1b0cd 100644 --- a/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CountExtensions.g.cs +++ b/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/CountExtensions.g.cs @@ -12,7 +12,7 @@ internal static partial class CountExtensions /// /// The number of separate metrics in the metric. /// - public const int Length = 63; + public const int Length = 69; /// /// Gets the metric name for the provided metric @@ -85,6 +85,12 @@ public static string GetName(this Datadog.Trace.Telemetry.Metrics.Count metric) Datadog.Trace.Telemetry.Metrics.Count.IastExecutedSinks => "executed.sink", Datadog.Trace.Telemetry.Metrics.Count.IastRequestTainted => "request.tainted", Datadog.Trace.Telemetry.Metrics.Count.IastSuppressedVulnerabilities => "suppressed.vulnerabilities", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationRowsDropped => "flagevaluation.rows.dropped", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationRowsDegraded => "flagevaluation.rows.degraded", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationPayloadSplits => "flagevaluation.payload.splits", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationContextTruncated => "flagevaluation.context.truncated", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationTargetingKeyOmitted => "flagevaluation.targeting_key.omitted", + Datadog.Trace.Telemetry.Metrics.Count.FlagEvaluationHookErrors => "flagevaluation.hook.errors", _ => null!, }; diff --git a/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/IMetricsTelemetryCollector_Count.g.cs b/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/IMetricsTelemetryCollector_Count.g.cs index d397e0881363..3c9eb975656a 100644 --- a/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/IMetricsTelemetryCollector_Count.g.cs +++ b/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/IMetricsTelemetryCollector_Count.g.cs @@ -134,4 +134,16 @@ internal partial interface IMetricsTelemetryCollector public void RecordCountIastRequestTainted(int increment = 1); public void RecordCountIastSuppressedVulnerabilities(Datadog.Trace.Telemetry.Metrics.MetricTags.IastVulnerabilityType tag, int increment = 1); + + public void RecordCountFlagEvaluationRowsDropped(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDropReason tag, int increment = 1); + + public void RecordCountFlagEvaluationRowsDegraded(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDegradeReason tag, int increment = 1); + + public void RecordCountFlagEvaluationPayloadSplits(int increment = 1); + + public void RecordCountFlagEvaluationContextTruncated(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationContextReason tag, int increment = 1); + + public void RecordCountFlagEvaluationTargetingKeyOmitted(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationTargetingReason tag, int increment = 1); + + public void RecordCountFlagEvaluationHookErrors(int increment = 1); } \ No newline at end of file diff --git a/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/MetricsTelemetryCollector_Count.g.cs b/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/MetricsTelemetryCollector_Count.g.cs index 5593f1cb4e54..bc109b9773d4 100644 --- a/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/MetricsTelemetryCollector_Count.g.cs +++ b/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/MetricsTelemetryCollector_Count.g.cs @@ -11,7 +11,7 @@ namespace Datadog.Trace.Telemetry; internal sealed partial class MetricsTelemetryCollector { - private const int CountLength = 800; + private const int CountLength = 820; /// /// Creates the buffer for the values. @@ -882,6 +882,32 @@ private static AggregatedMetric[] GetCountBuffer() new(new[] { "vulnerability_type:directory_listing_leak" }), new(new[] { "vulnerability_type:session_timeout" }), new(new[] { "vulnerability_type:email_html_injection" }), + // flagevaluation.rows.dropped, index = 800 + new(new[] { "reason:pre_queue_overflow" }), + new(new[] { "reason:queue_overflow" }), + new(new[] { "reason:closed" }), + new(new[] { "reason:degraded_cap" }), + new(new[] { "reason:payload_limit" }), + new(new[] { "reason:serialization_error" }), + // flagevaluation.rows.degraded, index = 806 + new(new[] { "reason:cardinality_cap" }), + new(new[] { "reason:payload_limit" }), + // flagevaluation.payload.splits, index = 808 + new(null), + // flagevaluation.context.truncated, index = 809 + new(new[] { "reason:max_context_fields" }), + new(new[] { "reason:max_key_length" }), + new(new[] { "reason:max_value_length" }), + new(new[] { "reason:max_list_elements" }), + new(new[] { "reason:max_structure_properties" }), + new(new[] { "reason:max_snapshot_depth" }), + new(new[] { "reason:max_visited_nodes" }), + new(new[] { "reason:unsupported_value" }), + new(new[] { "reason:snapshot_error" }), + // flagevaluation.targeting_key.omitted, index = 818 + new(new[] { "reason:invalid" }), + // flagevaluation.hook.errors, index = 819 + new(null), }; /// @@ -890,7 +916,7 @@ private static AggregatedMetric[] GetCountBuffer() /// It is equal to the cardinality of the tag combinations (or 1 if there are no tags) /// private static int[] CountEntryCounts { get; } - = new int[]{ 4, 85, 1, 3, 7, 2, 2, 7, 1, 1, 1, 22, 3, 2, 5, 5, 4, 1, 1, 22, 3, 34, 90, 90, 4, 4, 4, 4, 2, 44, 6, 1, 1, 85, 1, 22, 3, 8, 2, 10, 8, 4, 12, 4, 16, 2, 2, 10, 3, 4, 5, 15, 5, 20, 10, 4, 4, 5, 14, 1, 27, 1, 27, }; + = new int[]{ 4, 85, 1, 3, 7, 2, 2, 7, 1, 1, 1, 22, 3, 2, 5, 5, 4, 1, 1, 22, 3, 34, 90, 90, 4, 4, 4, 4, 2, 44, 6, 1, 1, 85, 1, 22, 3, 8, 2, 10, 8, 4, 12, 4, 16, 2, 2, 10, 3, 4, 5, 15, 5, 20, 10, 4, 4, 5, 14, 1, 27, 1, 27, 6, 2, 1, 9, 1, 1, }; public void RecordCountLogCreated(Datadog.Trace.Telemetry.Metrics.MetricTags.LogLevel tag, int increment = 1) { @@ -1259,4 +1285,38 @@ public void RecordCountIastSuppressedVulnerabilities(Datadog.Trace.Telemetry.Met var index = 773 + (int)tag; Interlocked.Add(ref _buffer.Count[index], increment); } + + public void RecordCountFlagEvaluationRowsDropped(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDropReason tag, int increment = 1) + { + var index = 800 + (int)tag; + Interlocked.Add(ref _buffer.Count[index], increment); + } + + public void RecordCountFlagEvaluationRowsDegraded(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDegradeReason tag, int increment = 1) + { + var index = 806 + (int)tag; + Interlocked.Add(ref _buffer.Count[index], increment); + } + + public void RecordCountFlagEvaluationPayloadSplits(int increment = 1) + { + Interlocked.Add(ref _buffer.Count[808], increment); + } + + public void RecordCountFlagEvaluationContextTruncated(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationContextReason tag, int increment = 1) + { + var index = 809 + (int)tag; + Interlocked.Add(ref _buffer.Count[index], increment); + } + + public void RecordCountFlagEvaluationTargetingKeyOmitted(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationTargetingReason tag, int increment = 1) + { + var index = 818 + (int)tag; + Interlocked.Add(ref _buffer.Count[index], increment); + } + + public void RecordCountFlagEvaluationHookErrors(int increment = 1) + { + Interlocked.Add(ref _buffer.Count[819], increment); + } } \ No newline at end of file diff --git a/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/NullMetricsTelemetryCollector_Count.g.cs b/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/NullMetricsTelemetryCollector_Count.g.cs index 7643b4aee31f..446d2c394222 100644 --- a/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/NullMetricsTelemetryCollector_Count.g.cs +++ b/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/TelemetryMetricGenerator/NullMetricsTelemetryCollector_Count.g.cs @@ -263,4 +263,28 @@ public void RecordCountIastRequestTainted(int increment = 1) public void RecordCountIastSuppressedVulnerabilities(Datadog.Trace.Telemetry.Metrics.MetricTags.IastVulnerabilityType tag, int increment = 1) { } + + public void RecordCountFlagEvaluationRowsDropped(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDropReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationRowsDegraded(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationDegradeReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationPayloadSplits(int increment = 1) + { + } + + public void RecordCountFlagEvaluationContextTruncated(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationContextReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationTargetingKeyOmitted(Datadog.Trace.Telemetry.Metrics.MetricTags.FlagEvaluationTargetingReason tag, int increment = 1) + { + } + + public void RecordCountFlagEvaluationHookErrors(int increment = 1) + { + } } \ No newline at end of file diff --git a/tracer/src/Datadog.Trace/Telemetry/Metrics/Count.cs b/tracer/src/Datadog.Trace/Telemetry/Metrics/Count.cs index 4fe455372498..ea8f43afd2c8 100644 --- a/tracer/src/Datadog.Trace/Telemetry/Metrics/Count.cs +++ b/tracer/src/Datadog.Trace/Telemetry/Metrics/Count.cs @@ -349,4 +349,25 @@ internal enum Count /// [TelemetryMetric("suppressed.vulnerabilities", isCommon: true, NS.Iast)] IastSuppressedVulnerabilities, #endregion + +#region Feature Flags (Tracers Namespace) + + /// Evaluations dropped locally, not aggregate bucket counts. + [TelemetryMetric("flagevaluation.rows.dropped", isCommon: true)] FlagEvaluationRowsDropped, + + /// Evaluations whose telemetry was degraded to omit identity and context. + [TelemetryMetric("flagevaluation.rows.degraded", isCommon: true)] FlagEvaluationRowsDegraded, + + /// Additional payloads created to respect the uncompressed payload limit. + [TelemetryMetric("flagevaluation.payload.splits", isCommon: true)] FlagEvaluationPayloadSplits, + + /// Evaluations with context omissions, counted once per fixed reason. + [TelemetryMetric("flagevaluation.context.truncated", isCommon: true)] FlagEvaluationContextTruncated, + + /// Evaluations with an invalid targeting key omitted from telemetry. + [TelemetryMetric("flagevaluation.targeting_key.omitted", isCommon: true)] FlagEvaluationTargetingKeyOmitted, + + /// Unexpected hook errors isolated from customer evaluations. + [TelemetryMetric("flagevaluation.hook.errors", isCommon: true)] FlagEvaluationHookErrors, +#endregion } diff --git a/tracer/src/Datadog.Trace/Telemetry/Metrics/MetricTags.cs b/tracer/src/Datadog.Trace/Telemetry/Metrics/MetricTags.cs index 46186eb401f3..ef65fc4b5e84 100644 --- a/tracer/src/Datadog.Trace/Telemetry/Metrics/MetricTags.cs +++ b/tracer/src/Datadog.Trace/Telemetry/Metrics/MetricTags.cs @@ -13,6 +13,41 @@ namespace Datadog.Trace.Telemetry.Metrics; [SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1134:Attributes should not share line", Justification = "It's easier to read")] internal static class MetricTags { + internal enum FlagEvaluationDropReason + { + [Description("reason:pre_queue_overflow")] PreQueueOverflow, + [Description("reason:queue_overflow")] QueueOverflow, + [Description("reason:closed")] Closed, + [Description("reason:degraded_cap")] DegradedCap, + [Description("reason:payload_limit")] PayloadLimit, + [Description("reason:serialization_error")] SerializationError, + } + + internal enum FlagEvaluationDegradeReason + { + [Description("reason:cardinality_cap")] CardinalityCap, + [Description("reason:payload_limit")] PayloadLimit, + } + + // Ordinals correspond to the bit positions in ContextOmissionReason. + internal enum FlagEvaluationContextReason + { + [Description("reason:max_context_fields")] MaxContextFields, + [Description("reason:max_key_length")] MaxKeyLength, + [Description("reason:max_value_length")] MaxValueLength, + [Description("reason:max_list_elements")] MaxListElements, + [Description("reason:max_structure_properties")] MaxStructureProperties, + [Description("reason:max_snapshot_depth")] MaxSnapshotDepth, + [Description("reason:max_visited_nodes")] MaxVisitedNodes, + [Description("reason:unsupported_value")] UnsupportedValue, + [Description("reason:snapshot_error")] SnapshotError, + } + + internal enum FlagEvaluationTargetingReason + { + [Description("reason:invalid")] Invalid, + } + internal enum LogLevel { [Description("level:debug")] Debug, diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationModuleTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationModuleTests.cs new file mode 100644 index 000000000000..7bdb48dbb8d4 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationModuleTests.cs @@ -0,0 +1,184 @@ +// +// 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.Concurrent; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Threading.Tasks; +using Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation; +using Datadog.Trace.Configuration; +using Datadog.Trace.Configuration.ConfigurationSources; +using Datadog.Trace.Configuration.Telemetry; +using Datadog.Trace.FeatureFlags; +using Datadog.Trace.FeatureFlags.FlagEvaluation; +using Datadog.Trace.TestHelpers; +using Datadog.Trace.Vendors.Newtonsoft.Json.Linq; +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; + +namespace Datadog.Trace.Tests.FeatureFlags; + +public class FlagEvaluationModuleTests(ITestOutputHelper output) +{ + private readonly ITestOutputHelper _output = output; + + [Theory] + [InlineData("remote_config")] + [InlineData("agentless")] + public async Task ActivationOwnsOneWriterIndependentOfConfigurationSource(string source) + { + using var agent = MockTracerAgent.Create(_output); + var received = new ConcurrentQueue(); + agent.EventPlatformProxyPayloadReceived += (_, args) => received.Enqueue(args.Value); + var delivery = new FakeDeliverySource(); + var module = FeatureFlagsModule.Create(Settings(agent.Port, source), new MockRcmSubscriptionManager(), _ => delivery)!; + try + { + module.EvaluationWriter.Should().BeNull("provider adoption activates the writer, not tracer startup"); + await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(module.Activate))); + var writer = module.EvaluationWriter!; + writer.Should().NotBeNull(); + module.Activate(); + module.EvaluationWriter.Should().BeSameAs(writer); + delivery.Started.Should().Be(source == "agentless" ? 1 : 0); + writer.TryEnqueue(Observation()).Should().BeTrue(); + await module.FlushAsync(); + received.Should().ContainSingle(); + var request = received.Single(); + request.PathAndQuery.Should().Be("/evp_proxy/v2/api/v2/flagevaluation"); + request.Headers["DD-API-KEY"].Should().BeNull(); + request.BodyInJson.Should().NotContain("private-subject"); + + writer.TryEnqueue(Observation()).Should().BeTrue(); + var close = module.DisposeAsync(); + module.EvaluationWriter.Should().BeNull(); + await close; + received.Should().HaveCount(2, "shutdown drains the accepted evaluation"); + writer.TryEnqueue(Observation()).Should().BeFalse(); + module.Activate(); + module.EvaluationWriter.Should().BeNull(); + await module.DisposeAsync(); + delivery.Disposed.Should().Be(source == "agentless" ? 1 : 0); + } + finally + { + await module.DisposeAsync(); + } + } + + [Fact] + public async Task ExporterAndServiceContextUpdatesApplyBeforeAndAfterActivation() + { + using var original = MockTracerAgent.Create(_output); + using var updated = MockTracerAgent.Create(_output); + var originalRequests = new ConcurrentQueue(); + var updatedRequests = new ConcurrentQueue(); + original.EventPlatformProxyPayloadReceived += (_, args) => originalRequests.Enqueue(args.Value); + updated.EventPlatformProxyPayloadReceived += (_, args) => updatedRequests.Enqueue(args.Value); + var settings = Settings(original.Port, "remote_config"); + var module = FeatureFlagsModule.Create(settings, new MockRcmSubscriptionManager())!; + try + { + UpdateSettings(settings, updated.Port, "before"); + module.Activate(); + var writer = module.EvaluationWriter!; + writer.TryEnqueue(Observation()).Should().BeTrue(); + await module.FlushAsync(); + originalRequests.Should().BeEmpty(); + updatedRequests.Should().ContainSingle(); + JObject.Parse(updatedRequests.Single().BodyInJson)["context"]!["env"]!.Value().Should().Be("before"); + + UpdateSettings(settings, original.Port, "after"); + writer.TryEnqueue(Observation()).Should().BeTrue(); + await module.FlushAsync(); + originalRequests.Should().ContainSingle(); + var context = JObject.Parse(originalRequests.Single().BodyInJson)["context"]!; + context["env"]!.Value().Should().Be("after"); + context["service"]!.Value().Should().Be("updated-service"); + context["version"]!.Value().Should().Be("2"); + await module.DisposeAsync(); + UpdateSettings(settings, updated.Port, "disposed"); + writer.TryEnqueue(Observation()).Should().BeFalse(); + await module.FlushAsync(); + updatedRequests.Should().ContainSingle(); + } + finally + { + await module.DisposeAsync(); + } + } + + [Fact] + public async Task ActivationRacingDisposalDoesNotLeaveAnAvailableWriter() + { + for (var i = 0; i < 20; i++) + { + var module = FeatureFlagsModule.Create(Settings(8126, "remote_config"), new MockRcmSubscriptionManager())!; + await Task.WhenAll(Task.Run(module.Activate), Task.Run(module.Dispose)); + await module.DisposeAsync(); + module.EvaluationWriter.Should().BeNull(); + module.Activate(); + module.EvaluationWriter.Should().BeNull(); + } + } + + [Fact] + public async Task FailingConfigurationSourceFactoryDoesNotStartAnotherWriterOnReactivation() + { + var module = FeatureFlagsModule.Create(Settings(8126, "agentless"), new MockRcmSubscriptionManager(), _ => throw new InvalidOperationException("source-factory-failure"))!; + try + { + Action activate = module.Activate; + activate.Should().Throw(); + var writer = module.EvaluationWriter; + writer.Should().NotBeNull(); + activate.Should().NotThrow("activation was already claimed, even if configuration-source startup failed"); + module.EvaluationWriter.Should().BeSameAs(writer); + } + finally + { + await module.DisposeAsync(); + } + } + + private static void UpdateSettings(TracerSettings settings, int port, string env) + => settings.Manager.UpdateManualConfigurationSettings( + new ManualInstrumentationConfigurationSource( + new Dictionary + { + [TracerSettingKeyConstants.AgentUriKey] = new Uri($"http://127.0.0.1:{port}"), + [TracerSettingKeyConstants.EnvironmentKey] = env, + [TracerSettingKeyConstants.ServiceNameKey] = "updated-service", + [TracerSettingKeyConstants.ServiceVersionKey] = "2", + }, + useDefaultSources: true), + NullConfigurationTelemetry.Instance); + + private static TracerSettings Settings(int port, string source) => new(new NameValueConfigurationSource(new NameValueCollection + { + [ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSource] = source, + [ConfigurationKeys.AgentUri] = $"http://127.0.0.1:{port}", + [ConfigurationKeys.ApiKey] = "config-only-secret", + [ConfigurationKeys.ServiceName] = "module-test", + })); + + private static FlagEvalEvent Observation() => new("flag", "variant", null, "private-subject", 1790000000000, null); + + private sealed class FakeDeliverySource : IFeatureFlagsDeliverySource + { + public int Started { get; private set; } + + public int Disposed { get; private set; } + + public void Start() => Started++; + + public void Dispose() => Disposed++; + } +} diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationWriterTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationWriterTests.cs new file mode 100644 index 000000000000..eb14f1f733e4 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationWriterTests.cs @@ -0,0 +1,607 @@ +// +// 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.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Datadog.Trace.FeatureFlags.FlagEvaluation; +using Datadog.Trace.Telemetry; +using Datadog.Trace.Vendors.Newtonsoft.Json.Linq; +using FluentAssertions; +using Moq; +using Xunit; + +namespace Datadog.Trace.Tests.FeatureFlags; + +public class FlagEvaluationWriterTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExplicitFlushSendsPrivacySafeCompressedAggregates(bool consent) + { + var bodies = new ConcurrentQueue(); + var writer = new FlagEvaluationWriter( + bytes => + { + bodies.Enqueue(Decompress(bytes)); + return Task.CompletedTask; + }, + Context); + try + { + writer.TryEnqueue(Observation(consent: consent)).Should().BeTrue(); + writer.TryEnqueue(Observation(consent: consent)).Should().BeTrue(); + await Completes(writer.FlushAsync()); + bodies.Should().ContainSingle(); + bodies.TryPeek(out var bytes).Should().BeTrue(); + var json = Encoding.UTF8.GetString(bytes!); + json.Should().NotContain("private-error-canary"); + if (consent) + { + json.Should().Contain("subject-canary").And.Contain("context-canary"); + } + else + { + json.Should().NotContain("subject-canary").And.NotContain("context-canary").And.Contain("sha256_"); + } + + var payload = JObject.Parse(json); + payload["flagEvaluations"]![0]!["evaluation_count"]!.Value().Should().Be(2); + payload["context"]!["service"]!.Value().Should().Be("writer-test"); + await Completes(writer.FlushAsync()); + bodies.Should().ContainSingle("a drained batch must not be sent again"); + } + finally + { + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + } + } + + [Fact] + public async Task SparseEvaluationFlushesWithoutExplicitRequest() + { + var sent = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var writer = new FlagEvaluationWriter( + bytes => + { + sent.TrySetResult(Decompress(bytes)); + return Task.CompletedTask; + }, + Context, + flushInterval: TimeSpan.FromMilliseconds(20)); + try + { + writer.TryEnqueue(Observation()).Should().BeTrue(); + await Completes(sent.Task); + JObject.Parse(Encoding.UTF8.GetString(await sent.Task))["flagEvaluations"]![0]!["evaluation_count"]!.Value().Should().Be(1); + } + finally + { + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + } + } + + [Fact] + public async Task BlockedSenderDoesNotBlockAdmissionOrGrowQueueBeyondCapacity() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var writer = new FlagEvaluationWriter( + _ => + { + entered.TrySetResult(true); + return release.Task; + }, + Context, + queueCap: 2); + try + { + writer.TryEnqueue(Observation()).Should().BeTrue(); + var flush = writer.FlushAsync(); + await Completes(entered.Task); + writer.TryEnqueue(Observation()).Should().BeTrue(); + writer.TryEnqueue(Observation()).Should().BeTrue(); + writer.HasCapacity().Should().BeFalse(); + var offers = Task.Run(() => + { + for (var i = 0; i < 1000; i++) + { + writer.TryEnqueue(Observation()).Should().BeFalse(); + } + }); + await Completes(offers); + flush.IsCompleted.Should().BeFalse(); + release.TrySetResult(true); + await Completes(flush); + } + finally + { + release.TrySetResult(true); + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + } + } + + [Fact] + public async Task CloseReturnsAtDeadlineAndStopsAdmissionDuringBlockedSend() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + var writer = new FlagEvaluationWriter( + _ => + { + Interlocked.Increment(ref calls); + entered.TrySetResult(true); + return release.Task; + }, + Context); + try + { + writer.TryEnqueue(Observation()).Should().BeTrue(); + var flush = writer.FlushAsync(); + await Completes(entered.Task); + writer.TryEnqueue(Observation()).Should().BeTrue(); + await Completes(writer.CloseAsync(TimeSpan.FromMilliseconds(50))); + release.Task.IsCompleted.Should().BeFalse(); + writer.HasCapacity().Should().BeFalse(); + writer.TryEnqueue(Observation()).Should().BeFalse(); + release.TrySetResult(true); + await Completes(flush); + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + Volatile.Read(ref calls).Should().Be(1, "timed-out close must not start another network batch"); + } + finally + { + release.TrySetResult(true); + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + } + } + + [Fact] + public async Task FailedBatchDoesNotPoisonFollowingFlush() + { + var calls = 0; + var bodies = new ConcurrentQueue(); + var writer = new FlagEvaluationWriter( + bytes => + { + if (Interlocked.Increment(ref calls) == 1) + { + throw new IOException("private-send-error-canary"); + } + + bodies.Enqueue(Decompress(bytes)); + return Task.CompletedTask; + }, + Context); + try + { + writer.TryEnqueue(Observation()).Should().BeTrue(); + await Completes(writer.FlushAsync()); + writer.TryEnqueue(Observation()).Should().BeTrue(); + await Completes(writer.FlushAsync()); + Volatile.Read(ref calls).Should().Be(2); + bodies.Should().ContainSingle(); + bodies.TryPeek(out var bytes).Should().BeTrue(); + JObject.Parse(Encoding.UTF8.GetString(bytes!))["flagEvaluations"]![0]!["evaluation_count"]!.Value().Should().Be(1); + } + finally + { + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + } + } + + [Fact] + public async Task EmptyWriterDoesNotSendOnFlushOrClose() + { + var calls = 0; + var writer = new FlagEvaluationWriter( + _ => + { + Interlocked.Increment(ref calls); + return Task.CompletedTask; + }, + Context); + await Completes(writer.FlushAsync()); + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + Volatile.Read(ref calls).Should().Be(0); + } + + [Fact] + public async Task QueueDropsAndSnapshotOmissionsAreCountedOnceAtAdmission() + { + var collector = new MetricsTelemetryCollector(Timeout.InfiniteTimeSpan); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var writer = new FlagEvaluationWriter( + _ => + { + entered.TrySetResult(true); + return release.Task; + }, + Context, + queueCap: 1, + metrics: collector); + try + { + writer.TryEnqueue(Observation()).Should().BeTrue(); + var flush = writer.FlushAsync(); + await Completes(entered.Task); + var oversized = new FlagEvalEvent("flag", "variant", null, "user", 1790000000000, new Dictionary { ["field"] = new string('x', 257) }, observeFullEvaluationData: true); + writer.TryEnqueue(oversized, (int)ContextOmissionReason.MaxValueLength).Should().BeTrue(); + writer.HasCapacity().Should().BeFalse(); + writer.TryEnqueue(Observation(), (int)ContextOmissionReason.SnapshotError).Should().BeFalse(); + release.TrySetResult(true); + await Completes(flush); + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + writer.TryEnqueue(Observation()).Should().BeFalse(); + + ReadMetrics(collector).Should().BeEquivalentTo(new Dictionary + { + ["flagevaluation.rows.dropped|reason:pre_queue_overflow"] = 1, + ["flagevaluation.rows.dropped|reason:queue_overflow"] = 1, + ["flagevaluation.rows.dropped|reason:closed"] = 1, + ["flagevaluation.context.truncated|reason:max_value_length"] = 1, + ["flagevaluation.context.truncated|reason:snapshot_error"] = 1, + }); + } + finally + { + release.TrySetResult(true); + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + await collector.DisposeAsync(); + } + } + + [Fact] + public async Task AggregationMetricsCountEvaluationsRatherThanBuckets() + { + var collector = new MetricsTelemetryCollector(Timeout.InfiniteTimeSpan); + var bodies = new ConcurrentQueue(); + var writer = new FlagEvaluationWriter( + bytes => + { + bodies.Enqueue(Decompress(bytes)); + return Task.CompletedTask; + }, + Context, + globalCap: 0, + degradedCap: 1, + metrics: collector); + try + { + writer.TryEnqueue(Observation()).Should().BeTrue(); + writer.TryEnqueue(Observation()).Should().BeTrue(); + writer.TryEnqueue(Observation(targetingKey: "\uD800")).Should().BeTrue(); + for (var i = 0; i < 3; i++) + { + writer.TryEnqueue(Observation(flag: "another-flag")).Should().BeTrue(); + } + + await Completes(writer.FlushAsync()); + ReadMetrics(collector).Should().BeEquivalentTo(new Dictionary + { + ["flagevaluation.rows.degraded|reason:cardinality_cap"] = 3, + ["flagevaluation.rows.dropped|reason:degraded_cap"] = 3, + ["flagevaluation.targeting_key.omitted|reason:invalid"] = 1, + }); + bodies.Should().ContainSingle(); + bodies.TryPeek(out var bytes).Should().BeTrue(); + var row = JObject.Parse(Encoding.UTF8.GetString(bytes!))["flagEvaluations"]![0]!; + row["evaluation_count"]!.Value().Should().Be(3); + row["targeting_key"].Should().BeNull(); + row["context"].Should().BeNull(); + } + finally + { + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + await collector.DisposeAsync(); + } + } + + [Fact] + public async Task ThrowingMetricSinkCannotLoseAnOtherwiseValidEvent() + { + var brokenCollector = new Mock(MockBehavior.Strict); + var bodies = new ConcurrentQueue(); + var writer = new FlagEvaluationWriter( + bytes => + { + bodies.Enqueue(Decompress(bytes)); + return Task.CompletedTask; + }, + Context, + metrics: brokenCollector.Object); + try + { + writer.TryEnqueue(Observation(targetingKey: "\uD800"), (int)ContextOmissionReason.SnapshotError).Should().BeTrue(); + await Completes(writer.FlushAsync()); + bodies.Should().ContainSingle(); + bodies.TryPeek(out var bytes).Should().BeTrue(); + var row = JObject.Parse(Encoding.UTF8.GetString(bytes!))["flagEvaluations"]![0]!; + row["evaluation_count"]!.Value().Should().Be(1); + row["targeting_key"].Should().BeNull(); + brokenCollector.Invocations.Should().HaveCount(2, "both admission and flush should attempt their metric despite the failing sink"); + } + finally + { + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + } + } + + [Fact] + public async Task FlushDuringBlockedSendWaitsForTheNextBatchAndCoalescesCallers() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var bodies = new ConcurrentQueue(); + var writer = new FlagEvaluationWriter( + bytes => + { + bodies.Enqueue(Decompress(bytes)); + entered.TrySetResult(true); + return release.Task; + }, + Context); + try + { + writer.TryEnqueue(Observation()).Should().BeTrue(); + var first = writer.FlushAsync(); + await Completes(entered.Task); + writer.TryEnqueue(Observation(flag: "next")).Should().BeTrue(); + var next = writer.FlushAsync(); + next.Should().NotBeSameAs(first); + writer.FlushAsync().Should().BeSameAs(next); + next.IsCompleted.Should().BeFalse(); + release.TrySetResult(true); + await Completes(next); + bodies.Should().HaveCount(2); + JObject.Parse(Encoding.UTF8.GetString(bodies.Last()))["flagEvaluations"]![0]!["flag"]!["key"]!.Value().Should().Be("next"); + } + finally + { + release.TrySetResult(true); + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + } + } + + [Fact] + public async Task ContinuousProducersDoNotStarvePeriodicFlushOrClose() + { + var sent = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + long accepted = 0; + long delivered = 0; + using var stop = new CancellationTokenSource(); + var writer = new FlagEvaluationWriter( + bytes => + { + var rows = JObject.Parse(Encoding.UTF8.GetString(Decompress(bytes)))["flagEvaluations"]!; + foreach (var row in rows) + { + Interlocked.Add(ref delivered, row["evaluation_count"]!.Value()); + } + + sent.TrySetResult(true); + return Task.CompletedTask; + }, + Context, + queueCap: 64, + flushInterval: TimeSpan.FromMilliseconds(20)); + var observation = Observation(); + var producers = Enumerable.Range(0, 4).Select(_ => Task.Run(() => + { + while (!stop.IsCancellationRequested) + { + if (writer.TryEnqueue(observation)) + { + Interlocked.Increment(ref accepted); + } + } + })).ToArray(); + try + { + await Completes(sent.Task); + stop.IsCancellationRequested.Should().BeFalse(); + await Completes(writer.CloseAsync(TimeSpan.FromSeconds(2))); + stop.Cancel(); + await Completes(Task.WhenAll(producers)); + delivered.Should().Be(accepted); + accepted.Should().BeGreaterThan(0); + } + finally + { + stop.Cancel(); + await Task.WhenAll(producers); + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + } + } + + [Fact] + public async Task AbandonedQueueIsReleasedByItsConsumerAndCountedAfterBlockedSendEnds() + { + var collector = new MetricsTelemetryCollector(Timeout.InfiniteTimeSpan); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var writer = new FlagEvaluationWriter( + _ => + { + entered.TrySetResult(true); + return release.Task; + }, + Context, + metrics: collector); + try + { + writer.TryEnqueue(Observation()).Should().BeTrue(); + var flush = writer.FlushAsync(); + await Completes(entered.Task); + var reference = EnqueueTrackedObservation(writer); + await writer.CloseAsync(TimeSpan.FromMilliseconds(20)); + reference.IsAlive.Should().BeTrue("the blocked consumer, not shutdown, owns queue cleanup"); + release.TrySetResult(true); + await Completes(flush); + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + reference.IsAlive.Should().BeFalse("the finished consumer must release abandoned observations"); + ReadMetrics(collector).Should().BeEquivalentTo(new Dictionary + { + ["flagevaluation.rows.dropped|reason:closed"] = 1, + }); + } + finally + { + release.TrySetResult(true); + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + await collector.DisposeAsync(); + } + } + + [Fact] + public async Task ContextFailureDropsTheBatchButDoesNotPoisonFollowingFlush() + { + var collector = new MetricsTelemetryCollector(Timeout.InfiniteTimeSpan); + var calls = 0; + var bodies = new ConcurrentQueue(); + var writer = new FlagEvaluationWriter( + bytes => + { + bodies.Enqueue(Decompress(bytes)); + return Task.CompletedTask; + }, + () => Interlocked.Increment(ref calls) == 1 ? throw new InvalidOperationException("private-context-error") : Context(), + metrics: collector); + try + { + writer.TryEnqueue(Observation()).Should().BeTrue(); + writer.TryEnqueue(Observation()).Should().BeTrue(); + await Completes(writer.FlushAsync()); + bodies.Should().BeEmpty(); + writer.TryEnqueue(Observation()).Should().BeTrue(); + await Completes(writer.FlushAsync()); + bodies.Should().ContainSingle(); + ReadMetrics(collector).Should().BeEquivalentTo(new Dictionary + { + ["flagevaluation.rows.dropped|reason:serialization_error"] = 2, + }); + } + finally + { + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + await collector.DisposeAsync(); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task PayloadLimitMetricsPreserveEvaluationUnitsAndGoodNeighbors(bool irreducible) + { + var collector = new MetricsTelemetryCollector(Timeout.InfiniteTimeSpan); + var bodies = new ConcurrentQueue(); + var writer = new FlagEvaluationWriter( + bytes => + { + bodies.Enqueue(Decompress(bytes)); + return Task.CompletedTask; + }, + Context, + payloadLimitBytes: 512, + metrics: collector); + try + { + writer.TryEnqueue(new FlagEvalEvent("before", "on", null, null, 1790000000000, null)).Should().BeTrue(); + for (var i = 0; i < 3; i++) + { + writer.TryEnqueue(new FlagEvalEvent(irreducible ? new string('f', 5000) : "large", "on", null, new string('t', 5000), 1790000000000, null, observeFullEvaluationData: true)).Should().BeTrue(); + } + + writer.TryEnqueue(new FlagEvalEvent("after", "on", null, null, 1790000000000, null)).Should().BeTrue(); + await Completes(writer.FlushAsync()); + bodies.Should().OnlyContain(body => body.Length <= 512); + var rows = bodies.SelectMany(body => JObject.Parse(Encoding.UTF8.GetString(body))["flagEvaluations"]!).ToList(); + rows.Sum(row => row["evaluation_count"]!.Value()).Should().Be(irreducible ? 2 : 5); + rows.Select(row => row["flag"]!["key"]!.Value()).Should().Contain("before").And.Contain("after"); + var expected = new Dictionary + { + [irreducible ? "flagevaluation.rows.dropped|reason:payload_limit" : "flagevaluation.rows.degraded|reason:payload_limit"] = 3, + }; + if (bodies.Count > 1) + { + expected["flagevaluation.payload.splits|"] = bodies.Count - 1; + } + + ReadMetrics(collector).Should().BeEquivalentTo(expected); + } + finally + { + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + await collector.DisposeAsync(); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference EnqueueTrackedObservation(FlagEvaluationWriter writer) + { + var observation = Observation(consent: true); + writer.TryEnqueue(observation).Should().BeTrue(); + return new WeakReference(observation); + } + + private static Dictionary ReadMetrics(MetricsTelemetryCollector collector) + { + collector.AggregateMetrics(); + var metrics = collector.GetMetrics().Metrics!; + metrics.Should().NotBeNull(); + // The tracer's default namespace is represented by an omitted namespace field. + metrics.Should().OnlyContain(metric => (metric.Namespace ?? "tracers") == "tracers" && metric.Common); + return metrics.ToDictionary( + metric => metric.Metric + "|" + string.Join(",", metric.Tags ?? []), + metric => metric.Points.Sum(point => (double)point.Value)); + } + + private static IReadOnlyDictionary Context() => new Dictionary + { + ["service"] = "writer-test", + ["env"] = "test", + ["version"] = "1", + }; + + private static FlagEvalEvent Observation(bool consent = false, string flag = "flag", string? targetingKey = "subject-canary") => new( + flag, + "variant", + "allocation", + targetingKey, + 1790000000000, + new Dictionary { ["country"] = "context-canary" }, + "private-error-canary", + consent); + + private static async Task Completes(Task task) + { + (await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(5)))).Should().BeSameAs(task); + await task; + } + + private static byte[] Decompress(ArraySegment bytes) + { + using var stream = new MemoryStream(bytes.Array!, bytes.Offset, bytes.Count); + using var gzip = new GZipStream(stream, CompressionMode.Decompress); + using var output = new MemoryStream(); + gzip.CopyTo(output); + return output.ToArray(); + } +} diff --git a/tracer/test/Datadog.Trace.Tests/Telemetry/Metrics/common_metrics.json b/tracer/test/Datadog.Trace.Tests/Telemetry/Metrics/common_metrics.json index b7e55f70965a..17aff6c228a5 100644 --- a/tracer/test/Datadog.Trace.Tests/Telemetry/Metrics/common_metrics.json +++ b/tracer/test/Datadog.Trace.Tests/Telemetry/Metrics/common_metrics.json @@ -114,6 +114,54 @@ } }, "tracers": { + "flagevaluation.rows.dropped": { + "tags": ["reason"], + "metric_type": "count", + "data_type": "evaluations", + "description": "Evaluations dropped locally, by fixed reason.", + "send_to_user": false, + "user_tags": [] + }, + "flagevaluation.rows.degraded": { + "tags": ["reason"], + "metric_type": "count", + "data_type": "evaluations", + "description": "Evaluations degraded to omit identity and context, by fixed reason.", + "send_to_user": false, + "user_tags": [] + }, + "flagevaluation.payload.splits": { + "tags": [], + "metric_type": "count", + "data_type": "payloads", + "description": "Additional payloads created to respect the uncompressed size limit.", + "send_to_user": false, + "user_tags": [] + }, + "flagevaluation.context.truncated": { + "tags": ["reason"], + "metric_type": "count", + "data_type": "evaluations", + "description": "Evaluations with context omissions, once per fixed reason.", + "send_to_user": false, + "user_tags": [] + }, + "flagevaluation.targeting_key.omitted": { + "tags": ["reason"], + "metric_type": "count", + "data_type": "evaluations", + "description": "Evaluations with an invalid targeting key omitted.", + "send_to_user": false, + "user_tags": [] + }, + "flagevaluation.hook.errors": { + "tags": [], + "metric_type": "count", + "data_type": "errors", + "description": "Unexpected hook errors isolated from customer evaluations.", + "send_to_user": false, + "user_tags": [] + }, "integration_errors": { "tags": [ "integration_name", From 4de2e6c67c7fe416b16541d8d60a1a3433cd71cb Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Wed, 30 Sep 2026 18:17:19 +0000 Subject: [PATCH 06/15] feat(ffe): wire OpenFeature EVP instrumentation and configuration --- Datadog.Trace.Build.g.sln | 7 + tracer/build/supported_calltargets.g.json | 76 +++++++++ tracer/missing-nullability-files.csv | 2 + .../OpenFeatureSdkCanEnqueueEVPIntegration.cs | 42 +++++ .../OpenFeatureSdkEnqueueEVPIntegration.cs | 66 ++++++++ ...FeatureSdkRecordEVPHookErrorIntegration.cs | 39 +++++ .../supported-configurations.yaml | 10 ++ .../FeatureFlags/FeatureFlagsModule.cs | 5 + .../FeatureFlags/FeatureFlagsSettings.cs | 10 ++ .../ConfigurationKeys.FeatureFlags.g.cs | 7 + .../InstrumentationDefinitions.g.cs | 3 + .../ConfigurationKeys.FeatureFlags.g.cs | 7 + .../InstrumentationDefinitions.g.cs | 3 + .../ConfigurationKeys.FeatureFlags.g.cs | 7 + .../InstrumentationDefinitions.g.cs | 3 + .../ConfigurationKeys.FeatureFlags.g.cs | 7 + .../InstrumentationDefinitions.g.cs | 3 + tracer/src/Datadog.Trace/Tracer.cs | 9 +- tracer/src/Datadog.Trace/TracerManager.cs | 4 +- .../Generated/generated_calltargets.g.cpp | 68 ++++---- .../FeatureFlags/FeatureFlagsSettingsTests.cs | 24 +++ .../FeatureFlags/FlagEvaluationBridgeTests.cs | 154 ++++++++++++++++++ .../FeatureFlags/FlagEvaluationModuleTests.cs | 30 ++++ .../FlagEvaluationTracerLifecycleTests.cs | 94 +++++++++++ 24 files changed, 644 insertions(+), 36 deletions(-) create mode 100644 tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/ManualInstrumentation/OpenFeature/OpenFeatureSdkCanEnqueueEVPIntegration.cs create mode 100644 tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/ManualInstrumentation/OpenFeature/OpenFeatureSdkEnqueueEVPIntegration.cs create mode 100644 tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/ManualInstrumentation/OpenFeature/OpenFeatureSdkRecordEVPHookErrorIntegration.cs create mode 100644 tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationBridgeTests.cs create mode 100644 tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationTracerLifecycleTests.cs diff --git a/Datadog.Trace.Build.g.sln b/Datadog.Trace.Build.g.sln index 438b3aaffbca..7cbafa90fb95 100644 --- a/Datadog.Trace.Build.g.sln +++ b/Datadog.Trace.Build.g.sln @@ -275,6 +275,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Datadog.AutoInstrumentation EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Datadog.AutoInstrumentation.Generator.Cli", "tracer\src\Datadog.AutoInstrumentation.Generator.Cli\Datadog.AutoInstrumentation.Generator.Cli.csproj", "{8D722514-8BAD-4069-84D0-15797026E0ED}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Datadog.FeatureFlags.OpenFeature.Tests", "tracer\test\Datadog.FeatureFlags.OpenFeature.Tests\Datadog.FeatureFlags.OpenFeature.Tests.csproj", "{372B229E-7999-4AB6-903E-BDAF958F1696}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -571,6 +573,10 @@ Global {8D722514-8BAD-4069-84D0-15797026E0ED}.Debug|Any CPU.Build.0 = Debug|Any CPU {8D722514-8BAD-4069-84D0-15797026E0ED}.Release|Any CPU.ActiveCfg = Release|Any CPU {8D722514-8BAD-4069-84D0-15797026E0ED}.Release|Any CPU.Build.0 = Release|Any CPU + {372B229E-7999-4AB6-903E-BDAF958F1696}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {372B229E-7999-4AB6-903E-BDAF958F1696}.Debug|Any CPU.Build.0 = Debug|Any CPU + {372B229E-7999-4AB6-903E-BDAF958F1696}.Release|Any CPU.ActiveCfg = Release|Any CPU + {372B229E-7999-4AB6-903E-BDAF958F1696}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {9518425A-36A5-4B8F-B0B8-6137DB88441D} = {8CEC2042-F11C-49F5-A674-2355793B600A} @@ -664,5 +670,6 @@ Global {CFECF8D4-3A46-35A8-7CB1-BA359974A1A9} = {9E5F0022-0A50-40BF-AC6A-C3078585ECAB} {B3A07CA3-3536-4D43-81A3-A5A73FB76F22} = {9E5F0022-0A50-40BF-AC6A-C3078585ECAB} {8D722514-8BAD-4069-84D0-15797026E0ED} = {9E5F0022-0A50-40BF-AC6A-C3078585ECAB} + {372B229E-7999-4AB6-903E-BDAF958F1696} = {8CEC2042-F11C-49F5-A674-2355793B600A} EndGlobalSection EndGlobal diff --git a/tracer/build/supported_calltargets.g.json b/tracer/build/supported_calltargets.g.json index 4af00e27f1a1..6e8871bfa54a 100644 --- a/tracer/build/supported_calltargets.g.json +++ b/tracer/build/supported_calltargets.g.json @@ -14114,6 +14114,60 @@ "IsAdoNetIntegration": false, "InstrumentationCategory": 1 }, + { + "IntegrationName": "OpenFeature", + "AssemblyName": "Datadog.FeatureFlags.OpenFeature", + "TargetTypeName": "Datadog.FeatureFlags.OpenFeature.FeatureFlagsSdk", + "TargetMethodName": "CanEnqueueEVP", + "TargetReturnType": "System.Boolean", + "TargetParameterTypes": [], + "MinimumVersion": { + "Item1": 2, + "Item2": 0, + "Item3": 0 + }, + "MaximumVersion": { + "Item1": 2, + "Item2": 65535, + "Item3": 65535 + }, + "InstrumentationTypeName": "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkCanEnqueueEVPIntegration", + "IntegrationKind": 0, + "IsAdoNetIntegration": false, + "InstrumentationCategory": 1 + }, + { + "IntegrationName": "OpenFeature", + "AssemblyName": "Datadog.FeatureFlags.OpenFeature", + "TargetTypeName": "Datadog.FeatureFlags.OpenFeature.FeatureFlagsSdk", + "TargetMethodName": "EnqueueEVP", + "TargetReturnType": "System.Void", + "TargetParameterTypes": [ + "System.String", + "System.String", + "System.String", + "System.String", + "System.Int64", + "System.Boolean", + "System.String", + "System.Collections.Generic.IReadOnlyDictionary`2[System.String,System.Object]", + "System.Int32" + ], + "MinimumVersion": { + "Item1": 2, + "Item2": 0, + "Item3": 0 + }, + "MaximumVersion": { + "Item1": 2, + "Item2": 65535, + "Item3": 65535 + }, + "InstrumentationTypeName": "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkEnqueueEVPIntegration", + "IntegrationKind": 0, + "IsAdoNetIntegration": false, + "InstrumentationCategory": 1 + }, { "IntegrationName": "OpenFeature", "AssemblyName": "Datadog.FeatureFlags.OpenFeature", @@ -14232,6 +14286,28 @@ "IsAdoNetIntegration": false, "InstrumentationCategory": 1 }, + { + "IntegrationName": "OpenFeature", + "AssemblyName": "Datadog.FeatureFlags.OpenFeature", + "TargetTypeName": "Datadog.FeatureFlags.OpenFeature.FeatureFlagsSdk", + "TargetMethodName": "RecordEVPHookError", + "TargetReturnType": "System.Void", + "TargetParameterTypes": [], + "MinimumVersion": { + "Item1": 2, + "Item2": 0, + "Item3": 0 + }, + "MaximumVersion": { + "Item1": 2, + "Item2": 65535, + "Item3": 65535 + }, + "InstrumentationTypeName": "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkRecordEVPHookErrorIntegration", + "IntegrationKind": 0, + "IsAdoNetIntegration": false, + "InstrumentationCategory": 1 + }, { "IntegrationName": "OpenFeature", "AssemblyName": "Datadog.FeatureFlags.OpenFeature", diff --git a/tracer/missing-nullability-files.csv b/tracer/missing-nullability-files.csv index 113080c920ec..12d8ae0e0af3 100644 --- a/tracer/missing-nullability-files.csv +++ b/tracer/missing-nullability-files.csv @@ -291,6 +291,8 @@ src/Datadog.Trace/Debugger/Snapshots/SnapshotPruner.cs src/Datadog.Trace/Debugger/Snapshots/SnapshotSerializerFieldsAndPropsSelector.cs src/Datadog.Trace/Debugger/Snapshots/SnapshotSummary.cs src/Datadog.Trace/Debugger/Snapshots/TaskSnapshotSerializerFieldsAndPropsSelector.cs +src/Datadog.Trace/FeatureFlags/FlagEvaluation/ContextOmissionReason.cs +src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAgentHeaderHelper.cs src/Datadog.Trace/Headers/Ip/ExtractedHeadersAndIpInfos.cs src/Datadog.Trace/Headers/Ip/IpInfo.cs src/Datadog.Trace/HttpOverStreams/HttpContent/BufferContent.cs diff --git a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/ManualInstrumentation/OpenFeature/OpenFeatureSdkCanEnqueueEVPIntegration.cs b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/ManualInstrumentation/OpenFeature/OpenFeatureSdkCanEnqueueEVPIntegration.cs new file mode 100644 index 000000000000..394899167f81 --- /dev/null +++ b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/ManualInstrumentation/OpenFeature/OpenFeatureSdkCanEnqueueEVPIntegration.cs @@ -0,0 +1,42 @@ +// +// 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.ComponentModel; +using Datadog.Trace.ClrProfiler.CallTarget; +using Datadog.Trace.Configuration; + +namespace Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature; + +/// +/// Checks flagevaluation queue capacity before the provider snapshots evaluation context. +/// +[InstrumentMethod( + AssemblyName = "Datadog.FeatureFlags.OpenFeature", + TypeName = "Datadog.FeatureFlags.OpenFeature.FeatureFlagsSdk", + MethodName = "CanEnqueueEVP", + ReturnTypeName = ClrNames.Bool, + ParameterTypeNames = [], + MinimumVersion = "2.0.0", + MaximumVersion = "2.*.*", + IntegrationName = nameof(IntegrationId.OpenFeature))] +[Browsable(false)] +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class OpenFeatureSdkCanEnqueueEVPIntegration +{ + internal static CallTargetReturn OnMethodEnd(bool returnValue, Exception? exception, in CallTargetState state) + { + try + { + return new CallTargetReturn(exception is null && TracerManager.Instance.FeatureFlags?.EvaluationWriter?.HasCapacity() == true); + } + catch (Exception) + { + OpenFeatureSdkRecordEVPHookErrorIntegration.RecordError(); + return new CallTargetReturn(false); + } + } +} diff --git a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/ManualInstrumentation/OpenFeature/OpenFeatureSdkEnqueueEVPIntegration.cs b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/ManualInstrumentation/OpenFeature/OpenFeatureSdkEnqueueEVPIntegration.cs new file mode 100644 index 000000000000..f9db5239dfe8 --- /dev/null +++ b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/ManualInstrumentation/OpenFeature/OpenFeatureSdkEnqueueEVPIntegration.cs @@ -0,0 +1,66 @@ +// +// 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 System.ComponentModel; +using Datadog.Trace.ClrProfiler.CallTarget; +using Datadog.Trace.Configuration; +using Datadog.Trace.FeatureFlags.FlagEvaluation; + +namespace Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature; + +/// +/// Hands provider-captured evaluation metadata to the tracer's bounded event queue. +/// +[InstrumentMethod( + AssemblyName = "Datadog.FeatureFlags.OpenFeature", + TypeName = "Datadog.FeatureFlags.OpenFeature.FeatureFlagsSdk", + MethodName = "EnqueueEVP", + ReturnTypeName = ClrNames.Void, + ParameterTypeNames = [ClrNames.String, ClrNames.String, ClrNames.String, ClrNames.String, ClrNames.Int64, ClrNames.Bool, ClrNames.String, "System.Collections.Generic.IReadOnlyDictionary`2[System.String,System.Object]", ClrNames.Int32], + MinimumVersion = "2.0.0", + MaximumVersion = "2.*.*", + IntegrationName = nameof(IntegrationId.OpenFeature))] +[Browsable(false)] +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class OpenFeatureSdkEnqueueEVPIntegration +{ + // Nine arguments use CallTarget's existing slow path, which does not support by-ref parameters. + internal static CallTargetState OnMethodBegin(string flagKey, string? variant, string? allocationKey, string? targetingKey, long evalTimeMs, bool consent, string? errorCode, IReadOnlyDictionary? attrs, int omissionReasons) + { + try + { + Enqueue(TracerManager.Instance.FeatureFlags?.EvaluationWriter, flagKey, variant, allocationKey, targetingKey, evalTimeMs, consent, errorCode, attrs, omissionReasons); + } + catch (Exception) + { + OpenFeatureSdkRecordEVPHookErrorIntegration.RecordError(); + } + + return CallTargetState.GetDefault(); + } + + internal static void Enqueue(FlagEvaluationWriter? writer, string flagKey, string? variant, string? allocationKey, string? targetingKey, long evalTimeMs, bool consent, string? errorCode, IReadOnlyDictionary? attrs, int omissionReasons) + { + if (writer is null) + { + return; + } + + try + { + // Constructor owns a bounded detached copy only with consent. Snapshot failures + // become a context omission, retaining the otherwise valid evaluation. + var observation = new FlagEvalEvent(flagKey, variant, allocationKey, targetingKey, evalTimeMs, attrs, errorCode, consent); + writer.TryEnqueue(observation, omissionReasons); + } + catch (Exception) + { + OpenFeatureSdkRecordEVPHookErrorIntegration.RecordError(); + } + } +} diff --git a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/ManualInstrumentation/OpenFeature/OpenFeatureSdkRecordEVPHookErrorIntegration.cs b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/ManualInstrumentation/OpenFeature/OpenFeatureSdkRecordEVPHookErrorIntegration.cs new file mode 100644 index 000000000000..79f53e4873d9 --- /dev/null +++ b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/ManualInstrumentation/OpenFeature/OpenFeatureSdkRecordEVPHookErrorIntegration.cs @@ -0,0 +1,39 @@ +// +// 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.ComponentModel; +using Datadog.Trace.ClrProfiler.CallTarget; +using Datadog.Trace.Configuration; +using Datadog.Trace.FeatureFlags.FlagEvaluation; +using Datadog.Trace.Telemetry; + +namespace Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature; + +/// +/// Records unexpected provider hook failures without including customer input or affecting evaluations. +/// +[InstrumentMethod( + AssemblyName = "Datadog.FeatureFlags.OpenFeature", + TypeName = "Datadog.FeatureFlags.OpenFeature.FeatureFlagsSdk", + MethodName = "RecordEVPHookError", + ReturnTypeName = ClrNames.Void, + ParameterTypeNames = [], + MinimumVersion = "2.0.0", + MaximumVersion = "2.*.*", + IntegrationName = nameof(IntegrationId.OpenFeature))] +[Browsable(false)] +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class OpenFeatureSdkRecordEVPHookErrorIntegration +{ + internal static CallTargetState OnMethodBegin() + { + RecordError(); + return CallTargetState.GetDefault(); + } + + internal static void RecordError(IMetricsTelemetryCollector? metrics = null) + => new FlagEvaluationTelemetry(metrics ?? TelemetryFactory.Metrics).HookError(); +} diff --git a/tracer/src/Datadog.Trace/Configuration/supported-configurations.yaml b/tracer/src/Datadog.Trace/Configuration/supported-configurations.yaml index ca598e687779..23993e8c4987 100644 --- a/tracer/src/Datadog.Trace/Configuration/supported-configurations.yaml +++ b/tracer/src/Datadog.Trace/Configuration/supported-configurations.yaml @@ -3256,6 +3256,16 @@ supportedConfigurations: documentation: |- Enables APM span enrichment with feature-flag evaluation metadata (Experimental). Default value is false (disabled). + DD_FLAGGING_EVALUATION_COUNTS_ENABLED: + - implementation: A + scope: managed + type: boolean + default: 'true' + product: FeatureFlags + documentation: |- + Enables Feature Flags flagevaluation track EVP events through the configured Datadog Agent. + Default value is true (enabled). Disabling this does not disable flag evaluation, + configuration fetching, experiment exposures, or span enrichment. DD_TRACE_KAFKA_CREATE_CONSUMER_SCOPE_ENABLED: - implementation: A scope: managed diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsModule.cs b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsModule.cs index 97d6954c6f41..d48128e70c22 100644 --- a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsModule.cs +++ b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsModule.cs @@ -366,6 +366,11 @@ internal bool ApplyConfiguration(ServerConfiguration configuration) private void StartEvaluationWriter() { + if (!_settings.EvaluationEventsEnabled) + { + return; + } + FlagEvaluationAgentSender? sender = null; FlagEvaluationWriter? writer = null; try diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsSettings.cs b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsSettings.cs index 86721c331c3b..f027629d62c7 100644 --- a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsSettings.cs +++ b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsSettings.cs @@ -92,6 +92,10 @@ public FeatureFlagsSettings(IConfigurationSource? source, IConfigurationTelemetr // set, which is exactly the case where the product runs on its default. telemetry.Record(ConfigurationKeys.FeatureFlags.FeatureFlagsEnabled, Enabled, ConfigurationOrigins.Calculated); + EvaluationEventsEnabled = config + .WithKeys(ConfigurationKeys.FeatureFlags.FlaggingEvaluationCountsEnabled) + .AsBool(true); + var agentlessBaseUrl = config .WithKeys(ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSourceAgentlessBaseUrl) .AsRedactedString(); @@ -134,6 +138,12 @@ public FeatureFlagsSettings(IConfigurationSource? source, IConfigurationTelemetr /// public bool Enabled { get; } + /// + /// Gets a value indicating whether flagevaluation track EVP events are enabled. + /// Independent of flag evaluation, configuration fetching, exposures, and span enrichment. + /// + public bool EvaluationEventsEnabled { get; } + /// /// Gets the configured override for the agentless endpoint, or null to derive it from the site. /// diff --git a/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs b/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs index 57e39c72c46c..7db4b49d9be1 100644 --- a/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs +++ b/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs @@ -85,6 +85,13 @@ internal static class FeatureFlags /// public const string FeatureFlagsEnabled = "DD_FEATURE_FLAGS_ENABLED"; + /// + /// Enables Feature Flags flagevaluation track EVP events through the configured Datadog Agent. + /// Default value is true (enabled). Disabling this does not disable flag evaluation, + /// configuration fetching, experiment exposures, or span enrichment. + /// + public const string FlaggingEvaluationCountsEnabled = "DD_FLAGGING_EVALUATION_COUNTS_ENABLED"; + /// /// Enables support for collecting and exporting logs generated by the the OpenTelemetry Logs API. /// This feature is available starting with .NET 3.1 when using Microsoft.Extensions.Logging diff --git a/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs b/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs index 9f8e7630b90c..d56a04049fff 100644 --- a/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs +++ b/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs @@ -549,11 +549,14 @@ internal static bool IsInstrumentedAssembly(string assemblyName) or "Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.NUnit.NUnitReflectInvokeMethodIntegration" => Datadog.Trace.Configuration.IntegrationId.NUnit, "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkAccumulateSpanEnrichmentIntegration" + or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkCanEnqueueEVPIntegration" + or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkEnqueueEVPIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkEvaluateIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkHasConfigurationIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkInitializeAsyncIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkIsAvailableIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkIsSpanEnrichmentEnabledIntegration" + or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkRecordEVPHookErrorIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkRegisterOnNewConfigEventHandlerIntegration" => Datadog.Trace.Configuration.IntegrationId.OpenFeature, "Datadog.Trace.ClrProfiler.AutoInstrumentation.OpenTelemetry.TracerProviderBuilderIntegration" diff --git a/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs b/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs index 57e39c72c46c..7db4b49d9be1 100644 --- a/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs +++ b/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs @@ -85,6 +85,13 @@ internal static class FeatureFlags /// public const string FeatureFlagsEnabled = "DD_FEATURE_FLAGS_ENABLED"; + /// + /// Enables Feature Flags flagevaluation track EVP events through the configured Datadog Agent. + /// Default value is true (enabled). Disabling this does not disable flag evaluation, + /// configuration fetching, experiment exposures, or span enrichment. + /// + public const string FlaggingEvaluationCountsEnabled = "DD_FLAGGING_EVALUATION_COUNTS_ENABLED"; + /// /// Enables support for collecting and exporting logs generated by the the OpenTelemetry Logs API. /// This feature is available starting with .NET 3.1 when using Microsoft.Extensions.Logging diff --git a/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs b/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs index 6de1416aad51..8557c2675bda 100644 --- a/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs +++ b/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs @@ -584,11 +584,14 @@ internal static bool IsInstrumentedAssembly(string assemblyName) or "Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.NUnit.NUnitReflectInvokeMethodIntegration" => Datadog.Trace.Configuration.IntegrationId.NUnit, "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkAccumulateSpanEnrichmentIntegration" + or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkCanEnqueueEVPIntegration" + or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkEnqueueEVPIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkEvaluateIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkHasConfigurationIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkInitializeAsyncIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkIsAvailableIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkIsSpanEnrichmentEnabledIntegration" + or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkRecordEVPHookErrorIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkRegisterOnNewConfigEventHandlerIntegration" => Datadog.Trace.Configuration.IntegrationId.OpenFeature, "Datadog.Trace.ClrProfiler.AutoInstrumentation.OpenTelemetry.TracerProviderBuilderIntegration" diff --git a/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs b/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs index 57e39c72c46c..7db4b49d9be1 100644 --- a/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs +++ b/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs @@ -85,6 +85,13 @@ internal static class FeatureFlags /// public const string FeatureFlagsEnabled = "DD_FEATURE_FLAGS_ENABLED"; + /// + /// Enables Feature Flags flagevaluation track EVP events through the configured Datadog Agent. + /// Default value is true (enabled). Disabling this does not disable flag evaluation, + /// configuration fetching, experiment exposures, or span enrichment. + /// + public const string FlaggingEvaluationCountsEnabled = "DD_FLAGGING_EVALUATION_COUNTS_ENABLED"; + /// /// Enables support for collecting and exporting logs generated by the the OpenTelemetry Logs API. /// This feature is available starting with .NET 3.1 when using Microsoft.Extensions.Logging diff --git a/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs b/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs index 37ea8aa93ca9..e3f736aebfe5 100644 --- a/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs +++ b/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs @@ -576,11 +576,14 @@ internal static bool IsInstrumentedAssembly(string assemblyName) or "Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.NUnit.NUnitReflectInvokeMethodIntegration" => Datadog.Trace.Configuration.IntegrationId.NUnit, "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkAccumulateSpanEnrichmentIntegration" + or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkCanEnqueueEVPIntegration" + or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkEnqueueEVPIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkEvaluateIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkHasConfigurationIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkInitializeAsyncIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkIsAvailableIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkIsSpanEnrichmentEnabledIntegration" + or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkRecordEVPHookErrorIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkRegisterOnNewConfigEventHandlerIntegration" => Datadog.Trace.Configuration.IntegrationId.OpenFeature, "Datadog.Trace.ClrProfiler.AutoInstrumentation.OpenTelemetry.TracerProviderBuilderIntegration" diff --git a/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs b/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs index 57e39c72c46c..7db4b49d9be1 100644 --- a/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs +++ b/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs @@ -85,6 +85,13 @@ internal static class FeatureFlags /// public const string FeatureFlagsEnabled = "DD_FEATURE_FLAGS_ENABLED"; + /// + /// Enables Feature Flags flagevaluation track EVP events through the configured Datadog Agent. + /// Default value is true (enabled). Disabling this does not disable flag evaluation, + /// configuration fetching, experiment exposures, or span enrichment. + /// + public const string FlaggingEvaluationCountsEnabled = "DD_FLAGGING_EVALUATION_COUNTS_ENABLED"; + /// /// Enables support for collecting and exporting logs generated by the the OpenTelemetry Logs API. /// This feature is available starting with .NET 3.1 when using Microsoft.Extensions.Logging diff --git a/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs b/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs index 37ea8aa93ca9..e3f736aebfe5 100644 --- a/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs +++ b/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/InstrumentationDefinitionsGenerator/InstrumentationDefinitions.g.cs @@ -576,11 +576,14 @@ internal static bool IsInstrumentedAssembly(string assemblyName) or "Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.NUnit.NUnitReflectInvokeMethodIntegration" => Datadog.Trace.Configuration.IntegrationId.NUnit, "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkAccumulateSpanEnrichmentIntegration" + or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkCanEnqueueEVPIntegration" + or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkEnqueueEVPIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkEvaluateIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkHasConfigurationIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkInitializeAsyncIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkIsAvailableIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkIsSpanEnrichmentEnabledIntegration" + or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkRecordEVPHookErrorIntegration" or "Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkRegisterOnNewConfigEventHandlerIntegration" => Datadog.Trace.Configuration.IntegrationId.OpenFeature, "Datadog.Trace.ClrProfiler.AutoInstrumentation.OpenTelemetry.TracerProviderBuilderIntegration" diff --git a/tracer/src/Datadog.Trace/Tracer.cs b/tracer/src/Datadog.Trace/Tracer.cs index da8450bc655a..93040b62575b 100644 --- a/tracer/src/Datadog.Trace/Tracer.cs +++ b/tracer/src/Datadog.Trace/Tracer.cs @@ -267,11 +267,16 @@ ISpan IDatadogOpenTracingTracer.StartSpan(string operationName, ISpanContext par } /// - /// Forces the tracer to immediately flush pending traces and send them to the agent. + /// Forces the tracer to immediately flush pending traces and feature-flag evaluation events to the agent. /// To be called when the appdomain or the process is about to be killed in a non-graceful way. /// /// Task used to track the async flush operation - public Task FlushAsync() => TracerManager.AgentWriter.FlushTracesAsync(); + public Task FlushAsync() + { + var manager = TracerManager; + var traces = manager.AgentWriter.FlushTracesAsync(); + return manager.FeatureFlags is { } flags ? Task.WhenAll(traces, flags.FlushAsync()) : traces; + } /// /// Writes the specified collection to the agent writer. diff --git a/tracer/src/Datadog.Trace/TracerManager.cs b/tracer/src/Datadog.Trace/TracerManager.cs index 5b1f57f728de..ea75160781f9 100644 --- a/tracer/src/Datadog.Trace/TracerManager.cs +++ b/tracer/src/Datadog.Trace/TracerManager.cs @@ -769,10 +769,10 @@ private static async Task RunShutdownTasksAsync(TracerManager instance, Timer he Log.Debug("Disposing RemoteConfigurationManager"); instance.RemoteConfigurationManager?.Dispose(); Log.Debug("Disposing FeatureFlagsManager"); - instance.FeatureFlags?.Dispose(); + var featureFlagsTask = instance.FeatureFlags?.DisposeAsync() ?? Task.CompletedTask; Log.Debug("Waiting for disposals."); - await Task.WhenAll(flushTracesTask, logSubmissionTask, discoveryService, dataStreamsTask).ConfigureAwait(false); + await Task.WhenAll(flushTracesTask, logSubmissionTask, discoveryService, dataStreamsTask, featureFlagsTask).ConfigureAwait(false); Log.Debug("Disposing Telemetry"); if (instance.Telemetry is { }) diff --git a/tracer/src/Datadog.Tracer.Native/Generated/generated_calltargets.g.cpp b/tracer/src/Datadog.Tracer.Native/Generated/generated_calltargets.g.cpp index 9491691ea0a0..78e80f942d1b 100644 --- a/tracer/src/Datadog.Tracer.Native/Generated/generated_calltargets.g.cpp +++ b/tracer/src/Datadog.Tracer.Native/Generated/generated_calltargets.g.cpp @@ -429,18 +429,19 @@ WCHAR* sig410[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("System.String"),(WCHA WCHAR* sig411[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("System.String"),(WCHAR*)WStr("System.String"),(WCHAR*)WStr("System.Boolean"),(WCHAR*)WStr("System.Boolean"),(WCHAR*)WStr("System.Boolean"),(WCHAR*)WStr("System.Boolean"),(WCHAR*)WStr("System.Boolean"),(WCHAR*)WStr("System.Collections.Generic.IDictionary`2[System.String,System.Object]"),}; WCHAR* sig412[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("System.String"),(WCHAR*)WStr("System.String"),(WCHAR*)WStr("System.Collections.Generic.IDictionary`2[System.String,System.String]"),}; WCHAR* sig413[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("System.String"),(WCHAR*)WStr("System.String"),(WCHAR*)WStr("System.String"),(WCHAR*)WStr("System.Collections.Generic.IDictionary`2[System.String,System.Object]"),}; -WCHAR* sig414[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("System.String"),(WCHAR*)WStr("System.UInt64"),(WCHAR*)WStr("System.Boolean"),(WCHAR*)WStr("System.String"),(WCHAR*)WStr("System.String"),(WCHAR*)WStr("RabbitMQ.Client.IBasicProperties"),(WCHAR*)WStr("_"),}; -WCHAR* sig415[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("System.TimeSpan"),}; -WCHAR* sig416[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("System.Type"),(WCHAR*)WStr("NLog.Internal.TargetWithFilterChain"),(WCHAR*)WStr("NLog.LogEventInfo"),(WCHAR*)WStr("NLog.LogFactory"),}; -WCHAR* sig417[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("System.Xml.XmlDictionaryWriter"),(WCHAR*)WStr("System.Object"),}; -WCHAR* sig418[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("System.Xml.XmlWriter"),(WCHAR*)WStr("System.Object"),}; -WCHAR* sig419[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("Xunit.v3.IXunitTestMethod"),(WCHAR*)WStr("System.Collections.Generic.IReadOnlyCollection`1[Xunit.v3.IXunitTestCase]"),(WCHAR*)WStr("Xunit.Sdk.ExplicitOption"),(WCHAR*)WStr("Xunit.v3.IMessageBus"),(WCHAR*)WStr("Xunit.v3.ExceptionAggregator"),(WCHAR*)WStr("System.Threading.CancellationTokenSource"),(WCHAR*)WStr("System.Object[]"),}; -WCHAR* sig420[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("Yarp.ReverseProxy.Forwarder.ForwarderHttpClientContext"),(WCHAR*)WStr("System.Net.Http.SocketsHttpHandler"),}; -WCHAR* sig421[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("_"),}; -WCHAR* sig422[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("_"),(WCHAR*)WStr("_"),}; -WCHAR* sig423[]={(WCHAR*)WStr("System.Web.Mvc.ActionResult"),(WCHAR*)WStr("System.Web.Mvc.ControllerContext"),(WCHAR*)WStr("System.Web.Mvc.ActionDescriptor"),(WCHAR*)WStr("System.Collections.Generic.IDictionary`2[System.String,System.Object]"),}; -WCHAR* sig424[]={(WCHAR*)WStr("_"),(WCHAR*)WStr("_"),}; -WCHAR* sig425[]={(WCHAR*)WStr("_"),(WCHAR*)WStr("_"),(WCHAR*)WStr("_"),}; +WCHAR* sig414[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("System.String"),(WCHAR*)WStr("System.String"),(WCHAR*)WStr("System.String"),(WCHAR*)WStr("System.String"),(WCHAR*)WStr("System.Int64"),(WCHAR*)WStr("System.Boolean"),(WCHAR*)WStr("System.String"),(WCHAR*)WStr("System.Collections.Generic.IReadOnlyDictionary`2[System.String,System.Object]"),(WCHAR*)WStr("System.Int32"),}; +WCHAR* sig415[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("System.String"),(WCHAR*)WStr("System.UInt64"),(WCHAR*)WStr("System.Boolean"),(WCHAR*)WStr("System.String"),(WCHAR*)WStr("System.String"),(WCHAR*)WStr("RabbitMQ.Client.IBasicProperties"),(WCHAR*)WStr("_"),}; +WCHAR* sig416[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("System.TimeSpan"),}; +WCHAR* sig417[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("System.Type"),(WCHAR*)WStr("NLog.Internal.TargetWithFilterChain"),(WCHAR*)WStr("NLog.LogEventInfo"),(WCHAR*)WStr("NLog.LogFactory"),}; +WCHAR* sig418[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("System.Xml.XmlDictionaryWriter"),(WCHAR*)WStr("System.Object"),}; +WCHAR* sig419[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("System.Xml.XmlWriter"),(WCHAR*)WStr("System.Object"),}; +WCHAR* sig420[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("Xunit.v3.IXunitTestMethod"),(WCHAR*)WStr("System.Collections.Generic.IReadOnlyCollection`1[Xunit.v3.IXunitTestCase]"),(WCHAR*)WStr("Xunit.Sdk.ExplicitOption"),(WCHAR*)WStr("Xunit.v3.IMessageBus"),(WCHAR*)WStr("Xunit.v3.ExceptionAggregator"),(WCHAR*)WStr("System.Threading.CancellationTokenSource"),(WCHAR*)WStr("System.Object[]"),}; +WCHAR* sig421[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("Yarp.ReverseProxy.Forwarder.ForwarderHttpClientContext"),(WCHAR*)WStr("System.Net.Http.SocketsHttpHandler"),}; +WCHAR* sig422[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("_"),}; +WCHAR* sig423[]={(WCHAR*)WStr("System.Void"),(WCHAR*)WStr("_"),(WCHAR*)WStr("_"),}; +WCHAR* sig424[]={(WCHAR*)WStr("System.Web.Mvc.ActionResult"),(WCHAR*)WStr("System.Web.Mvc.ControllerContext"),(WCHAR*)WStr("System.Web.Mvc.ActionDescriptor"),(WCHAR*)WStr("System.Collections.Generic.IDictionary`2[System.String,System.Object]"),}; +WCHAR* sig425[]={(WCHAR*)WStr("_"),(WCHAR*)WStr("_"),}; +WCHAR* sig426[]={(WCHAR*)WStr("_"),(WCHAR*)WStr("_"),(WCHAR*)WStr("_"),}; std::vector callTargets = { @@ -482,9 +483,9 @@ std::vector callTargets = {(WCHAR*)WStr("AerospikeClient"),(WCHAR*)WStr("Aerospike.Client.AsyncCommand"),(WCHAR*)WStr("ExecuteCommand"),sig333,1,4,0,0,8,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Aerospike.AsyncCommandIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("AerospikeClient"),(WCHAR*)WStr("Aerospike.Client.SyncCommand"),(WCHAR*)WStr("ExecuteCommand"),sig333,1,4,0,0,8,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Aerospike.SyncCommandIntegration"),CallTargetKind::Default,1,15}, #if _WIN32 -{(WCHAR*)WStr("System.Runtime.Serialization"),(WCHAR*)WStr("System.Runtime.Serialization.Json.DataContractJsonSerializer"),(WCHAR*)WStr("WriteObject"),sig417,3,4,0,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.DataContractJsonSerializer_WriteObject_XmlDictionaryWriter_Integration"),CallTargetKind::Default,2,1}, +{(WCHAR*)WStr("System.Runtime.Serialization"),(WCHAR*)WStr("System.Runtime.Serialization.Json.DataContractJsonSerializer"),(WCHAR*)WStr("WriteObject"),sig418,3,4,0,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.DataContractJsonSerializer_WriteObject_XmlDictionaryWriter_Integration"),CallTargetKind::Default,2,1}, {(WCHAR*)WStr("System.Runtime.Serialization"),(WCHAR*)WStr("System.Runtime.Serialization.XmlObjectSerializer"),(WCHAR*)WStr("WriteObject"),sig385,3,4,0,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.DataContractJsonSerializer_WriteObject_Integration"),CallTargetKind::Derived,2,1}, -{(WCHAR*)WStr("System.Runtime.Serialization"),(WCHAR*)WStr("System.Runtime.Serialization.XmlObjectSerializer"),(WCHAR*)WStr("WriteObject"),sig418,3,4,0,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.DataContractJsonSerializer_WriteObject_XmlWriter_Integration"),CallTargetKind::Derived,2,1}, +{(WCHAR*)WStr("System.Runtime.Serialization"),(WCHAR*)WStr("System.Runtime.Serialization.XmlObjectSerializer"),(WCHAR*)WStr("WriteObject"),sig419,3,4,0,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.DataContractJsonSerializer_WriteObject_XmlWriter_Integration"),CallTargetKind::Derived,2,1}, {(WCHAR*)WStr("System.Web"),(WCHAR*)WStr("System.Web.Compilation.BuildManager"),(WCHAR*)WStr("InvokePreStartInitMethodsCore"),sig371,3,4,0,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.HttpModule_Integration"),CallTargetKind::Default,1,1}, {(WCHAR*)WStr("System.Web"),(WCHAR*)WStr("System.Web.ThreadContext"),(WCHAR*)WStr("AssociateWithCurrentThread"),sig366,2,4,0,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.ThreadContext_AssociateWithCurrentThread_Integration"),CallTargetKind::Default,1,1}, {(WCHAR*)WStr("System.Web"),(WCHAR*)WStr("System.Web.ThreadContext"),(WCHAR*)WStr("DisassociateFromCurrentThread"),sig333,1,4,0,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.ThreadContext_DisassociateFromCurrentThread_Integration"),CallTargetKind::Default,1,1}, @@ -507,7 +508,7 @@ std::vector callTargets = {(WCHAR*)WStr("Microsoft.AspNetCore.Server.Kestrel.Core"),(WCHAR*)WStr("Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpProtocol"),(WCHAR*)WStr("FireOnStarting"),sig188,1,2,0,0,11,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNetCore.FireOnStartCommon"),CallTargetKind::Default,6,14}, {(WCHAR*)WStr("Microsoft.AspNetCore.Server.Kestrel.Core"),(WCHAR*)WStr("Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServer"),(WCHAR*)WStr("StartAsync"),sig204,3,2,2,0,3,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNetCore.EndpointsCollection.KestrelServerImplStartAsyncIntegration"),CallTargetKind::Default,1,14}, {(WCHAR*)WStr("Microsoft.AspNetCore.Server.Kestrel.Core"),(WCHAR*)WStr("Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl"),(WCHAR*)WStr("StartAsync"),sig204,3,5,0,0,11,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNetCore.EndpointsCollection.KestrelServerImplStartAsyncIntegration"),CallTargetKind::Default,1,14}, -{(WCHAR*)WStr("Microsoft.AspNetCore.Session"),(WCHAR*)WStr("Microsoft.AspNetCore.Builder.SessionOptions"),(WCHAR*)WStr("set_IdleTimeout"),sig415,2,2,0,0,11,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNetCore.Session.SessionOptionsIdleTimeoutIntegration"),CallTargetKind::Default,4,15}, +{(WCHAR*)WStr("Microsoft.AspNetCore.Session"),(WCHAR*)WStr("Microsoft.AspNetCore.Builder.SessionOptions"),(WCHAR*)WStr("set_IdleTimeout"),sig416,2,2,0,0,11,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNetCore.Session.SessionOptionsIdleTimeoutIntegration"),CallTargetKind::Default,4,15}, {(WCHAR*)WStr("Microsoft.AspNetCore.StaticFiles"),(WCHAR*)WStr("Microsoft.AspNetCore.Builder.DirectoryBrowserExtensions"),(WCHAR*)WStr("UseDirectoryBrowser"),sig075,2,2,0,0,11,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNetCore.StaticFiles.DirectoryBrowserExtensionsUseDirectoryBrowserIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Microsoft.AspNetCore.StaticFiles"),(WCHAR*)WStr("Microsoft.AspNetCore.Builder.DirectoryBrowserExtensions"),(WCHAR*)WStr("UseDirectoryBrowser"),sig076,3,2,0,0,11,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNetCore.StaticFiles.DirectoryBrowserExtensionsUseDirectoryBrowserIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Microsoft.Extensions.Identity.Core"),(WCHAR*)WStr("Microsoft.AspNetCore.Identity.UserManager`1"),(WCHAR*)WStr("CreateAsync"),sig276,2,2,0,0,11,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNetCore.UserEvents.UserManagerCreateIntegration"),CallTargetKind::Default,2,14}, @@ -516,7 +517,7 @@ std::vector callTargets = {(WCHAR*)WStr("System.Web.Mvc"),(WCHAR*)WStr("System.Web.Mvc.Async.AsyncControllerActionInvoker"),(WCHAR*)WStr("BeginInvokeAction"),sig155,5,4,0,0,5,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.AsyncControllerActionInvoker_BeginInvokeAction_Integration"),CallTargetKind::Default,1,1}, {(WCHAR*)WStr("System.Web.Mvc"),(WCHAR*)WStr("System.Web.Mvc.Async.AsyncControllerActionInvoker"),(WCHAR*)WStr("BeginInvokeActionMethod"),sig156,6,4,0,0,5,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.AsyncControllerActionInvoker_BeginInvokeActionMethod_Integration"),CallTargetKind::Default,1,1}, {(WCHAR*)WStr("System.Web.Mvc"),(WCHAR*)WStr("System.Web.Mvc.Async.AsyncControllerActionInvoker"),(WCHAR*)WStr("EndInvokeAction"),sig134,2,4,0,0,5,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.AsyncControllerActionInvoker_EndInvokeAction_Integration"),CallTargetKind::Default,1,1}, -{(WCHAR*)WStr("System.Web.Mvc"),(WCHAR*)WStr("System.Web.Mvc.ControllerActionInvoker"),(WCHAR*)WStr("InvokeActionMethod"),sig423,4,4,0,0,5,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.ControllerActionInvoker_InvokeAction_Integration"),CallTargetKind::Default,7,1}, +{(WCHAR*)WStr("System.Web.Mvc"),(WCHAR*)WStr("System.Web.Mvc.ControllerActionInvoker"),(WCHAR*)WStr("InvokeActionMethod"),sig424,4,4,0,0,5,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.ControllerActionInvoker_InvokeAction_Integration"),CallTargetKind::Default,7,1}, {(WCHAR*)WStr("System.Web.Http"),(WCHAR*)WStr("System.Web.Http.ApiController"),(WCHAR*)WStr("ExecuteAsync"),sig318,3,5,1,0,5,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.ApiController_ExecuteAsync_Integration"),CallTargetKind::Default,1,1}, {(WCHAR*)WStr("System.Web.Http"),(WCHAR*)WStr("System.Web.Http.Controllers.ReflectedHttpActionDescriptor"),(WCHAR*)WStr("ExecuteAsync"),sig317,4,5,1,0,5,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.ReflectedHttpActionDescriptor_ExecuteAsync_Integration"),CallTargetKind::Default,7,1}, {(WCHAR*)WStr("System.Web.Http"),(WCHAR*)WStr("System.Web.Http.ExceptionHandling.ExceptionHandlerExtensions"),(WCHAR*)WStr("HandleAsync"),sig319,4,5,1,0,5,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AspNet.ExceptionHandlerExtensions_HandleAsync_Integration"),CallTargetKind::Default,1,1}, @@ -823,7 +824,7 @@ std::vector callTargets = {(WCHAR*)WStr("System.Net.Http"),(WCHAR*)WStr("System.Net.Http.SocketsHttpHandler"),(WCHAR*)WStr("SendAsync"),sig316,3,4,0,0,11,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Http.HttpClient.SocketsHttpHandler.SocketsHttpHandlerIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("System.Net.Http"),(WCHAR*)WStr("System.Net.Http.WinHttpHandler"),(WCHAR*)WStr("SendAsync"),sig316,3,4,0,0,11,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Http.HttpClient.WinHttpHandler.WinHttpHandlerIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("System.Net.Http.WinHttpHandler"),(WCHAR*)WStr("System.Net.Http.WinHttpHandler"),(WCHAR*)WStr("SendAsync"),sig316,3,4,0,0,11,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Http.HttpClient.WinHttpHandler.WinHttpHandlerIntegration"),CallTargetKind::Default,1,15}, -{(WCHAR*)WStr("Yarp.ReverseProxy"),(WCHAR*)WStr("Yarp.ReverseProxy.Forwarder.ForwarderHttpClientFactory"),(WCHAR*)WStr("ConfigureHandler"),sig420,3,1,1,0,2,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Http.HttpClient.SocketsHttpHandler.YarpForwarderHttpClientFactoryIntegration"),CallTargetKind::Default,1,8}, +{(WCHAR*)WStr("Yarp.ReverseProxy"),(WCHAR*)WStr("Yarp.ReverseProxy.Forwarder.ForwarderHttpClientFactory"),(WCHAR*)WStr("ConfigureHandler"),sig421,3,1,1,0,2,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Http.HttpClient.SocketsHttpHandler.YarpForwarderHttpClientFactoryIntegration"),CallTargetKind::Default,1,8}, {(WCHAR*)WStr("amqmdnetstd"),(WCHAR*)WStr("IBM.WMQ.MQDestination"),(WCHAR*)WStr("Get"),sig350,4,9,0,0,10,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.IbmMq.GetIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("amqmdnetstd"),(WCHAR*)WStr("IBM.WMQ.MQDestination"),(WCHAR*)WStr("Put"),sig351,3,9,0,0,10,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.IbmMq.PutIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Microsoft.Extensions.Logging"),(WCHAR*)WStr("Microsoft.Extensions.Logging.LoggerFactory"),(WCHAR*)WStr(".ctor"),sig375,3,2,0,0,3,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Logging.ILogger.DirectSubmission.LoggerFactoryConstructorIntegration"),CallTargetKind::Default,1,15}, @@ -890,11 +891,11 @@ std::vector callTargets = {(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestAssemblyInfo"),(WCHAR*)WStr("RunAssemblyInitialize"),sig355,2,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestAssemblyInfoRunAssemblyInitializeIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestAssemblyInfo"),(WCHAR*)WStr("RunAssemblyInitialize"),sig356,3,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestAssemblyInfoRunAssemblyInitializeIntegrationV3_9"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("ExecuteClassCleanup"),sig333,1,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoExecuteClassCleanupIntegration"),CallTargetKind::Default,1,15}, -{(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("ExecuteClassCleanup"),sig425,3,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoExecuteClassCleanupIntegrationV3_9"),CallTargetKind::Default,1,15}, -{(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("ExecuteClassCleanup"),sig424,2,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoRunClassCleanupIntegration"),CallTargetKind::Default,1,15}, +{(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("ExecuteClassCleanup"),sig426,3,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoExecuteClassCleanupIntegrationV3_9"),CallTargetKind::Default,1,15}, +{(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("ExecuteClassCleanup"),sig425,2,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoRunClassCleanupIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("RunClassCleanup"),sig187,2,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoRunClassCleanupIntegration"),CallTargetKind::Default,1,15}, -{(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("RunClassInitialize"),sig421,2,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoRunClassInitializeIntegration"),CallTargetKind::Default,1,15}, -{(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("RunClassInitialize"),sig422,3,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoRunClassInitializeIntegrationV3_9"),CallTargetKind::Default,1,15}, +{(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("RunClassInitialize"),sig422,2,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoRunClassInitializeIntegration"),CallTargetKind::Default,1,15}, +{(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("RunClassInitialize"),sig423,3,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoRunClassInitializeIntegrationV3_9"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestMethodRunner"),(WCHAR*)WStr("Execute"),sig090,1,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestMethodRunnerExecuteIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestMethodRunner"),(WCHAR*)WStr("Execute"),sig092,5,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestMethodRunnerExecuteIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestMethodRunner"),(WCHAR*)WStr("Execute"),sig094,1,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestMethodRunnerExecuteIntegration3_8"),CallTargetKind::Default,1,15}, @@ -912,12 +913,12 @@ std::vector callTargets = {(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestAssemblyInfo"),(WCHAR*)WStr("RunAssemblyInitialize"),sig355,2,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestAssemblyInfoRunAssemblyInitializeIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestAssemblyInfo"),(WCHAR*)WStr("RunAssemblyInitialize"),sig356,3,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestAssemblyInfoRunAssemblyInitializeIntegrationV3_9"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("ExecuteClassCleanup"),sig333,1,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoExecuteClassCleanupIntegration"),CallTargetKind::Default,1,15}, -{(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("ExecuteClassCleanup"),sig425,3,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoExecuteClassCleanupIntegrationV3_9"),CallTargetKind::Default,1,15}, -{(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("ExecuteClassCleanup"),sig424,2,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoRunClassCleanupIntegration"),CallTargetKind::Default,1,15}, -{(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("ExecuteClassCleanupAsync"),sig424,2,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoExecuteClassCleanupAsyncIntegration"),CallTargetKind::Default,1,15}, +{(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("ExecuteClassCleanup"),sig426,3,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoExecuteClassCleanupIntegrationV3_9"),CallTargetKind::Default,1,15}, +{(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("ExecuteClassCleanup"),sig425,2,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoRunClassCleanupIntegration"),CallTargetKind::Default,1,15}, +{(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("ExecuteClassCleanupAsync"),sig425,2,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoExecuteClassCleanupAsyncIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("RunClassCleanup"),sig187,2,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoRunClassCleanupIntegration"),CallTargetKind::Default,1,15}, -{(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("RunClassInitialize"),sig421,2,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoRunClassInitializeIntegration"),CallTargetKind::Default,1,15}, -{(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("RunClassInitialize"),sig422,3,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoRunClassInitializeIntegrationV3_9"),CallTargetKind::Default,1,15}, +{(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("RunClassInitialize"),sig422,2,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoRunClassInitializeIntegration"),CallTargetKind::Default,1,15}, +{(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("RunClassInitialize"),sig423,3,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoRunClassInitializeIntegrationV3_9"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("RunClassInitializeAsync"),sig221,2,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoRunClassInitializeAsyncIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestMethodRunner"),(WCHAR*)WStr("ExecuteTestAsync"),sig287,2,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestMethodRunnerExecuteTestIntegrationV3_9"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TypeCache"),(WCHAR*)WStr("GetTestMethodInfo"),sig089,4,14,0,0,14,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TypeCacheGetTestMethodInfoIntegration"),CallTargetKind::Default,1,15}, @@ -934,7 +935,7 @@ std::vector callTargets = {(WCHAR*)WStr("MSTest.TestFramework"),(WCHAR*)WStr("Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute"),(WCHAR*)WStr("ExecuteAsync"),sig293,2,4,0,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestMethodAttributeExecuteAsyncIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("MSTest.TestFramework"),(WCHAR*)WStr("Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute"),(WCHAR*)WStr("ExecuteAsync"),sig293,2,4,0,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestMethodAttributeExecuteAsyncIntegration"),CallTargetKind::Derived,1,15}, {(WCHAR*)WStr("MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestAssemblyInfo"),(WCHAR*)WStr("RunAssemblyInitialize"),sig355,2,4,0,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestAssemblyInfoRunAssemblyInitializeIntegration"),CallTargetKind::Default,1,15}, -{(WCHAR*)WStr("MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("ExecuteClassCleanupAsync"),sig424,2,4,0,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoExecuteClassCleanupAsyncIntegration"),CallTargetKind::Default,1,15}, +{(WCHAR*)WStr("MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("ExecuteClassCleanupAsync"),sig425,2,4,0,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoExecuteClassCleanupAsyncIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestClassInfo"),(WCHAR*)WStr("RunClassInitializeAsync"),sig221,2,4,0,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestClassInfoRunClassInitializeAsyncIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestMethodRunner"),(WCHAR*)WStr("ExecuteTestAsync"),sig287,2,4,0,0,4,2,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestMethodRunnerExecuteTestIntegrationV3_9"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("MSTestAdapter.PlatformServices"),(WCHAR*)WStr("Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestMethodRunner"),(WCHAR*)WStr("ExecuteTestAsync"),sig292,3,4,3,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.MsTestV2.TestMethodRunnerExecuteTestIntegrationV4_3"),CallTargetKind::Default,1,15}, @@ -1002,8 +1003,8 @@ std::vector callTargets = {(WCHAR*)WStr("NLog"),(WCHAR*)WStr("NLog.LogFactory"),(WCHAR*)WStr("BuildLoggerConfiguration"),sig105,3,5,0,0,5,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Logging.NLog.DirectSubmission.LogFactoryBuildLoggerConfiguration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("NLog"),(WCHAR*)WStr("NLog.LogFactory"),(WCHAR*)WStr("BuildLoggerConfiguration"),sig401,3,5,0,0,5,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Logging.NLog.DirectSubmission.LogFactoryGetConfigurationForLoggerInstrumentation"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("NLog"),(WCHAR*)WStr("NLog.LogFactory"),(WCHAR*)WStr("GetConfigurationForLogger"),sig401,3,2,1,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Logging.NLog.DirectSubmission.LogFactoryGetConfigurationForLoggerInstrumentation"),CallTargetKind::Default,1,15}, -{(WCHAR*)WStr("NLog"),(WCHAR*)WStr("NLog.LoggerImpl"),(WCHAR*)WStr("Write"),sig416,5,1,0,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Logging.NLog.LogsInjection.LoggerImplWriteIntegrationV4"),CallTargetKind::Default,1,15}, -{(WCHAR*)WStr("NLog"),(WCHAR*)WStr("NLog.LoggerImpl"),(WCHAR*)WStr("Write"),sig416,5,5,0,0,6,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Logging.NLog.LogsInjection.LoggerImplWriteIntegrationV5"),CallTargetKind::Default,1,15}, +{(WCHAR*)WStr("NLog"),(WCHAR*)WStr("NLog.LoggerImpl"),(WCHAR*)WStr("Write"),sig417,5,1,0,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Logging.NLog.LogsInjection.LoggerImplWriteIntegrationV4"),CallTargetKind::Default,1,15}, +{(WCHAR*)WStr("NLog"),(WCHAR*)WStr("NLog.LoggerImpl"),(WCHAR*)WStr("Write"),sig417,5,5,0,0,6,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Logging.NLog.LogsInjection.LoggerImplWriteIntegrationV5"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Npgsql"),(WCHAR*)WStr("Npgsql.NpgsqlCommand"),(WCHAR*)WStr("ExecuteDbDataReader"),sig145,2,4,0,0,10,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AdoNet.CommandExecuteReaderWithBehaviorIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Npgsql"),(WCHAR*)WStr("Npgsql.NpgsqlCommand"),(WCHAR*)WStr("ExecuteDbDataReaderAsync"),sig312,3,4,0,0,10,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AdoNet.CommandExecuteReaderWithBehaviorAndCancellationAsyncIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Npgsql"),(WCHAR*)WStr("Npgsql.NpgsqlCommand"),(WCHAR*)WStr("ExecuteNonQuery"),sig157,1,4,0,0,10,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.AdoNet.CommandExecuteNonQueryIntegration"),CallTargetKind::Default,1,15}, @@ -1027,11 +1028,14 @@ std::vector callTargets = {(WCHAR*)WStr("nunit.framework"),(WCHAR*)WStr("NUnit.Framework.Internal.Execution.WorkItem"),(WCHAR*)WStr("WorkItemComplete"),sig333,1,3,0,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.NUnit.NUnitWorkItemWorkItemCompleteIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("nunit.framework"),(WCHAR*)WStr("NUnit.Framework.Internal.Reflect"),(WCHAR*)WStr("InvokeMethod"),sig178,4,3,0,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.NUnit.NUnitReflectInvokeMethodIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Datadog.FeatureFlags.OpenFeature"),(WCHAR*)WStr("Datadog.FeatureFlags.OpenFeature.FeatureFlagsSdk"),(WCHAR*)WStr("AccumulateSpanEnrichment"),sig390,7,2,0,0,2,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkAccumulateSpanEnrichmentIntegration"),CallTargetKind::Default,1,15}, +{(WCHAR*)WStr("Datadog.FeatureFlags.OpenFeature"),(WCHAR*)WStr("Datadog.FeatureFlags.OpenFeature.FeatureFlagsSdk"),(WCHAR*)WStr("CanEnqueueEVP"),sig131,1,2,0,0,2,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkCanEnqueueEVPIntegration"),CallTargetKind::Default,1,15}, +{(WCHAR*)WStr("Datadog.FeatureFlags.OpenFeature"),(WCHAR*)WStr("Datadog.FeatureFlags.OpenFeature.FeatureFlagsSdk"),(WCHAR*)WStr("EnqueueEVP"),sig414,10,2,0,0,2,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkEnqueueEVPIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Datadog.FeatureFlags.OpenFeature"),(WCHAR*)WStr("Datadog.FeatureFlags.OpenFeature.FeatureFlagsSdk"),(WCHAR*)WStr("Evaluate"),sig061,6,2,0,0,2,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkEvaluateIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Datadog.FeatureFlags.OpenFeature"),(WCHAR*)WStr("Datadog.FeatureFlags.OpenFeature.FeatureFlagsSdk"),(WCHAR*)WStr("HasConfiguration"),sig131,1,2,0,0,2,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkHasConfigurationIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Datadog.FeatureFlags.OpenFeature"),(WCHAR*)WStr("Datadog.FeatureFlags.OpenFeature.FeatureFlagsSdk"),(WCHAR*)WStr("InitializeAsync"),sig220,2,2,0,0,2,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkInitializeAsyncIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Datadog.FeatureFlags.OpenFeature"),(WCHAR*)WStr("Datadog.FeatureFlags.OpenFeature.FeatureFlagsSdk"),(WCHAR*)WStr("IsAvailable"),sig131,1,2,0,0,2,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkIsAvailableIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Datadog.FeatureFlags.OpenFeature"),(WCHAR*)WStr("Datadog.FeatureFlags.OpenFeature.FeatureFlagsSdk"),(WCHAR*)WStr("IsSpanEnrichmentEnabled"),sig131,1,2,0,0,2,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkIsSpanEnrichmentEnabledIntegration"),CallTargetKind::Default,1,15}, +{(WCHAR*)WStr("Datadog.FeatureFlags.OpenFeature"),(WCHAR*)WStr("Datadog.FeatureFlags.OpenFeature.FeatureFlagsSdk"),(WCHAR*)WStr("RecordEVPHookError"),sig333,1,2,0,0,2,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkRecordEVPHookErrorIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("Datadog.FeatureFlags.OpenFeature"),(WCHAR*)WStr("Datadog.FeatureFlags.OpenFeature.FeatureFlagsSdk"),(WCHAR*)WStr("RegisterOnNewConfigEventHandler"),sig364,2,2,0,0,2,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature.OpenFeatureSdkRegisterOnNewConfigEventHandlerIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("OpenTelemetry"),(WCHAR*)WStr("OpenTelemetry.Trace.TracerProviderBuilderExtensions"),(WCHAR*)WStr("Build"),sig120,2,1,0,0,1,0,0,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.OpenTelemetry.TracerProviderBuilderIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("OpenTelemetry.Api"),(WCHAR*)WStr("OpenTelemetry.Baggage"),(WCHAR*)WStr("ClearBaggage"),sig114,2,1,0,0,1,0,0,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.OpenTelemetry.OTelBaggage_ClearBaggageIntegration"),CallTargetKind::Default,1,15}, @@ -1103,13 +1107,13 @@ std::vector callTargets = {(WCHAR*)WStr("Google.Protobuf"),(WCHAR*)WStr("Google.Protobuf.IMessage"),(WCHAR*)WStr("WriteTo"),sig347,2,3,0,0,3,12,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Protobuf.OldMessageWriteToIntegration"),CallTargetKind::Interface,1,15}, {(WCHAR*)WStr("RabbitMQ.Client"),(WCHAR*)WStr("RabbitMQ.Client.AsyncDefaultBasicConsumer"),(WCHAR*)WStr("HandleBasicDeliver"),sig218,8,3,6,9,6,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.RabbitMQ.BasicDeliverAsyncIntegration"),CallTargetKind::Derived,1,15}, {(WCHAR*)WStr("RabbitMQ.Client"),(WCHAR*)WStr("RabbitMQ.Client.AsyncDefaultBasicConsumer"),(WCHAR*)WStr("HandleBasicDeliverAsync"),sig219,9,7,0,0,7,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.RabbitMQ.BasicDeliverAsyncAsyncIntegration"),CallTargetKind::Derived,1,15}, -{(WCHAR*)WStr("RabbitMQ.Client"),(WCHAR*)WStr("RabbitMQ.Client.DefaultBasicConsumer"),(WCHAR*)WStr("HandleBasicDeliver"),sig414,8,3,6,9,6,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.RabbitMQ.BasicDeliverIntegration"),CallTargetKind::Derived,1,15}, +{(WCHAR*)WStr("RabbitMQ.Client"),(WCHAR*)WStr("RabbitMQ.Client.DefaultBasicConsumer"),(WCHAR*)WStr("HandleBasicDeliver"),sig415,8,3,6,9,6,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.RabbitMQ.BasicDeliverIntegration"),CallTargetKind::Derived,1,15}, {(WCHAR*)WStr("RabbitMQ.Client"),(WCHAR*)WStr("RabbitMQ.Client.Framing.Impl.Model"),(WCHAR*)WStr("_Private_BasicPublish"),sig410,6,3,6,9,6,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.RabbitMQ.BasicPublishIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("RabbitMQ.Client"),(WCHAR*)WStr("RabbitMQ.Client.Framing.Impl.Model"),(WCHAR*)WStr("_Private_ExchangeDeclare"),sig411,9,3,6,9,6,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.RabbitMQ.ExchangeDeclareIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("RabbitMQ.Client"),(WCHAR*)WStr("RabbitMQ.Client.Framing.Impl.Model"),(WCHAR*)WStr("_Private_QueueDeclare"),sig404,8,3,6,9,6,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.RabbitMQ.QueueDeclareIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("RabbitMQ.Client"),(WCHAR*)WStr("RabbitMQ.Client.IAsyncBasicConsumer"),(WCHAR*)WStr("HandleBasicDeliver"),sig218,8,3,6,9,6,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.RabbitMQ.BasicDeliverAsyncIntegration"),CallTargetKind::Interface,1,15}, {(WCHAR*)WStr("RabbitMQ.Client"),(WCHAR*)WStr("RabbitMQ.Client.IAsyncBasicConsumer"),(WCHAR*)WStr("HandleBasicDeliverAsync"),sig219,9,7,0,0,7,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.RabbitMQ.BasicDeliverAsyncAsyncIntegration"),CallTargetKind::Interface,1,15}, -{(WCHAR*)WStr("RabbitMQ.Client"),(WCHAR*)WStr("RabbitMQ.Client.IBasicConsumer"),(WCHAR*)WStr("HandleBasicDeliver"),sig414,8,3,6,9,6,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.RabbitMQ.BasicDeliverIntegration"),CallTargetKind::Interface,1,15}, +{(WCHAR*)WStr("RabbitMQ.Client"),(WCHAR*)WStr("RabbitMQ.Client.IBasicConsumer"),(WCHAR*)WStr("HandleBasicDeliver"),sig415,8,3,6,9,6,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.RabbitMQ.BasicDeliverIntegration"),CallTargetKind::Interface,1,15}, {(WCHAR*)WStr("RabbitMQ.Client"),(WCHAR*)WStr("RabbitMQ.Client.Impl.Channel"),(WCHAR*)WStr("BasicConsumeAsync"),sig322,9,7,0,0,7,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.RabbitMQ.BasicConsumeAsyncIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("RabbitMQ.Client"),(WCHAR*)WStr("RabbitMQ.Client.Impl.Channel"),(WCHAR*)WStr("BasicGetAsync"),sig307,4,7,0,0,7,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.RabbitMQ.BasicGetAsyncIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("RabbitMQ.Client"),(WCHAR*)WStr("RabbitMQ.Client.Impl.Channel"),(WCHAR*)WStr("BasicPublishAsync"),sig325,7,7,0,0,7,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.RabbitMQ.BasicPublishAsyncCachedStringsIntegration"),CallTargetKind::Default,1,15}, @@ -1306,7 +1310,7 @@ std::vector callTargets = {(WCHAR*)WStr("xunit.v3.core"),(WCHAR*)WStr("Xunit.v3.TestRunnerBase`2"),(WCHAR*)WStr("Run"),sig329,2,4,0,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.XUnit.V3_4X.XUnitTestRunnerBaseRunV3_4XIntegration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("xunit.v3.core"),(WCHAR*)WStr("Xunit.v3.XunitTestMethodRunnerBase`3"),(WCHAR*)WStr("RunTestCase"),sig330,3,1,0,0,3,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.XUnit.V3.XUnitTestMethodRunnerBaseRunTestCaseV3Integration"),CallTargetKind::Default,1,15}, {(WCHAR*)WStr("xunit.v3.core"),(WCHAR*)WStr("Xunit.v3.XunitTestMethodRunnerBaseContext`2"),(WCHAR*)WStr("RunTestCase"),sig331,2,4,0,0,4,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.XUnit.V3_4X.XUnitTestMethodRunnerBaseContextRunTestCaseV3_4XIntegration"),CallTargetKind::Default,1,15}, -{(WCHAR*)WStr("xunit.v3.core"),(WCHAR*)WStr("Xunit.v3.XunitTestMethodRunnerContext"),(WCHAR*)WStr(".ctor"),sig419,8,1,0,0,3,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.XUnit.V3.XunitTestMethodRunnerContextCtorV3Integration"),CallTargetKind::Default,1,15}, +{(WCHAR*)WStr("xunit.v3.core"),(WCHAR*)WStr("Xunit.v3.XunitTestMethodRunnerContext"),(WCHAR*)WStr(".ctor"),sig420,8,1,0,0,3,65535,65535,assemblyName,(WCHAR*)WStr("Datadog.Trace.ClrProfiler.AutoInstrumentation.Testing.XUnit.V3.XunitTestMethodRunnerContextCtorV3Integration"),CallTargetKind::Default,1,15}, }; return profiler->RegisterCallTargetDefinitions((WCHAR*) WStr("Tracing"), callTargets.data(), callTargets.size(), enabledCategories, platform); } diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FeatureFlagsSettingsTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FeatureFlagsSettingsTests.cs index 79d67eedb464..304bd698d332 100644 --- a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FeatureFlagsSettingsTests.cs +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FeatureFlagsSettingsTests.cs @@ -17,6 +17,30 @@ namespace Datadog.Trace.Tests.FeatureFlags; public class FeatureFlagsSettingsTests { + [Theory] + [InlineData(null, true)] + [InlineData("true", true)] + [InlineData("false", false)] + [InlineData("0", false)] + [InlineData("1", true)] + [InlineData("not-a-boolean", true)] + public void EvaluationEventsUseIndependentDefaultOnKillSwitch(string? configured, bool expected) + { + var values = new NameValueCollection + { + [ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSource] = "remote_config", + }; + if (configured is not null) + { + values[ConfigurationKeys.FeatureFlags.FlaggingEvaluationCountsEnabled] = configured; + } + + var settings = new FeatureFlagsSettings(new NameValueConfigurationSource(values), NullConfigurationTelemetry.Instance); + settings.EvaluationEventsEnabled.Should().Be(expected); + settings.Enabled.Should().BeTrue(); + settings.Source.Should().Be(FeatureFlagsSource.RemoteConfig); + } + // The source-selection contract is shared across tracers, so these cases mirror the // system-tests parametric suite (tests/parametric/test_ffe/test_configuration_sources.py). // Where configuration would come from and whether Feature Flags run are asserted separately: diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationBridgeTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationBridgeTests.cs new file mode 100644 index 000000000000..d02e764faf12 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationBridgeTests.cs @@ -0,0 +1,154 @@ +// +// 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.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Datadog.Trace.ClrProfiler; +using Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature; +using Datadog.Trace.ClrProfiler.CallTarget; +using Datadog.Trace.ClrProfiler.CallTarget.Handlers; +using Datadog.Trace.Configuration; +using Datadog.Trace.FeatureFlags.FlagEvaluation; +using Datadog.Trace.Telemetry; +using Datadog.Trace.Vendors.Newtonsoft.Json.Linq; +using FluentAssertions; +using Moq; +using Xunit; + +namespace Datadog.Trace.Tests.FeatureFlags; + +public class FlagEvaluationBridgeTests +{ + [Theory] + [InlineData(typeof(OpenFeatureSdkCanEnqueueEVPIntegration))] + [InlineData(typeof(OpenFeatureSdkEnqueueEVPIntegration))] + [InlineData(typeof(OpenFeatureSdkRecordEVPHookErrorIntegration))] + public void GeneratedDefinitionsRegisterCallbacksAsOpenFeature(Type integration) + => InstrumentationDefinitions.GetIntegrationId(integration.FullName!, typeof(object)).Should().Be(IntegrationId.OpenFeature); + + [Fact] + public void NineArgumentCallbackCanBindThroughExistingCallTargetSlowPath() + { + var integration = typeof(OpenFeatureSdkEnqueueEVPIntegration); + var callback = integration.GetMethod("OnMethodBegin", BindingFlags.Static | BindingFlags.NonPublic)!; + callback.GetParameters().Should().HaveCount(9).And.OnlyContain(parameter => !parameter.ParameterType.IsByRef); + var generated = IntegrationMapper.CreateSlowBeginMethodDelegate(integration, typeof(object))!; + var invoke = (Func)generated.CreateDelegate(typeof(Func)); + Action call = () => invoke(new object(), ["flag", "on", null!, "subject", 1790000000000L, false, null!, null!, 0]); + call.Should().NotThrow(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DefensiveSnapshotFailurePreservesEvaluationAndProtectedModeNeverReadsAttributes(bool consent) + { + var collector = new MetricsTelemetryCollector(Timeout.InfiniteTimeSpan); + var bodies = new ConcurrentQueue(); + var writer = Writer(bodies, collector); + var attributes = new Mock>(MockBehavior.Strict); + attributes.SetupGet(value => value.Count).Throws(new InvalidOperationException("private-snapshot-error")); + try + { + OpenFeatureSdkEnqueueEVPIntegration.Enqueue(writer, "flag", "on", "allocation", "private-subject", 1790000000000, consent, "private-error", attributes.Object, 0); + await writer.FlushAsync(); + bodies.Should().ContainSingle(); + var body = bodies.Single(); + body.Should().NotContain("private-error").And.NotContain("private-snapshot-error"); + var row = JObject.Parse(body)["flagEvaluations"]![0]!; + row["evaluation_count"]!.Value().Should().Be(1); + row["first_evaluation"]!.Value().Should().Be(1790000000000); + row["allocation"]!["key"]!.Value().Should().Be("allocation"); + row["context"].Should().BeNull(); + row["targeting_key"]!.Value().Should().Be(consent ? "private-subject" : FlagEvaluationPrivacy.TargetingKeyForOutput("private-subject", false)); + attributes.VerifyGet(value => value.Count, consent ? Times.Once() : Times.Never()); + collector.AggregateMetrics(); + var metrics = collector.GetMetrics().Metrics; + if (consent) + { + metrics.Should().ContainSingle(); + var metric = metrics!.Single(); + metric.Metric.Should().Be("flagevaluation.context.truncated"); + metric.Tags.Should().Equal("reason:snapshot_error"); + metric.Points.Sum(point => point.Value).Should().Be(1); + } + else + { + metrics.Should().BeNullOrEmpty(); + } + } + finally + { + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + await collector.DisposeAsync(); + } + } + + [Fact] + public async Task BridgeDetachesConsentedAttributesAndForwardsProviderOmissionReasons() + { + var collector = new MetricsTelemetryCollector(Timeout.InfiniteTimeSpan); + var bodies = new ConcurrentQueue(); + var writer = Writer(bodies, collector); + var attributes = new Dictionary { ["country"] = "before", ["oversized"] = new string('x', 257) }; + try + { + OpenFeatureSdkEnqueueEVPIntegration.Enqueue(writer, "flag", "on", null, "subject", 1790000000000, true, null, attributes, (int)ContextOmissionReason.MaxValueLength); + attributes["country"] = "after"; + await writer.FlushAsync(); + bodies.Should().ContainSingle(); + JObject.Parse(bodies.Single())["flagEvaluations"]![0]!["context"]!["evaluation"]!["country"]!.Value().Should().Be("before"); + collector.AggregateMetrics(); + var metric = collector.GetMetrics().Metrics!.Should().ContainSingle().Subject; + metric.Metric.Should().Be("flagevaluation.context.truncated"); + metric.Tags.Should().Equal("reason:max_value_length"); + metric.Points.Sum(point => point.Value).Should().Be(1, "the provider and defensive normalization reported the same reason"); + } + finally + { + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + await collector.DisposeAsync(); + } + } + + [Fact] + public void MissingWriterNeverInspectsContext() + { + var attributes = new Mock>(MockBehavior.Strict); + OpenFeatureSdkEnqueueEVPIntegration.Enqueue(null, "flag", null, null, "subject", 1790000000000, true, null, attributes.Object, 0); + attributes.Invocations.Should().BeEmpty(); + } + + [Fact] + public void UnexpectedHookErrorIsBestEffortEvenWhenTheMetricSinkThrows() + { + var collector = new Mock(MockBehavior.Strict); + Action record = () => OpenFeatureSdkRecordEVPHookErrorIntegration.RecordError(collector.Object); + record.Should().NotThrow(); + collector.Verify(metrics => metrics.RecordCountFlagEvaluationHookErrors(1), Times.Once); + } + + private static FlagEvaluationWriter Writer(ConcurrentQueue bodies, IMetricsTelemetryCollector metrics) => new( + bytes => + { + using var compressed = new MemoryStream(bytes.Array!, bytes.Offset, bytes.Count); + using var gzip = new GZipStream(compressed, CompressionMode.Decompress); + using var reader = new StreamReader(gzip, Encoding.UTF8); + bodies.Enqueue(reader.ReadToEnd()); + return Task.CompletedTask; + }, + () => new Dictionary { ["service"] = "bridge-test" }, + metrics: metrics); +} diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationModuleTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationModuleTests.cs index 7bdb48dbb8d4..9f4401012c85 100644 --- a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationModuleTests.cs +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationModuleTests.cs @@ -25,10 +25,40 @@ namespace Datadog.Trace.Tests.FeatureFlags; +[Collection(nameof(WebRequestCollection))] public class FlagEvaluationModuleTests(ITestOutputHelper output) { private readonly ITestOutputHelper _output = output; + [Theory] + [InlineData("remote_config")] + [InlineData("agentless")] + public async Task EventKillSwitchDoesNotDisableConfigurationOrExposure(string source) + { + var values = new NameValueCollection + { + [ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSource] = source, + [ConfigurationKeys.FeatureFlags.FlaggingEvaluationCountsEnabled] = "false", + }; + var delivery = new FakeDeliverySource(); + var rcm = new MockRcmSubscriptionManager(); + var module = FeatureFlagsModule.Create(new TracerSettings(new NameValueConfigurationSource(values)), rcm, _ => delivery)!; + try + { + module.Should().NotBeNull(); + module.Activate(); + module.EvaluationWriter.Should().BeNull("disabled events must not allocate a sender or background consumer"); + delivery.Started.Should().Be(source == "agentless" ? 1 : 0); + rcm.HasAnySubscription.Should().Be(source == "remote_config"); + module.GetExposureApi().Should().NotBeNull("explicit experiment exposures have a separate lifecycle"); + await module.FlushAsync(); + } + finally + { + await module.DisposeAsync(); + } + } + [Theory] [InlineData("remote_config")] [InlineData("agentless")] diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationTracerLifecycleTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationTracerLifecycleTests.cs new file mode 100644 index 000000000000..228af17a2e94 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationTracerLifecycleTests.cs @@ -0,0 +1,94 @@ +// +// 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.Concurrent; +using System.Collections.Specialized; +using System.Threading.Tasks; +using Datadog.Trace.Agent; +using Datadog.Trace.Configuration; +using Datadog.Trace.FeatureFlags.FlagEvaluation; +using Datadog.Trace.Telemetry; +using Datadog.Trace.TestHelpers; +using Datadog.Trace.TestHelpers.TestTracer; +using FluentAssertions; +using Moq; +using Xunit; +using Xunit.Abstractions; + +namespace Datadog.Trace.Tests.FeatureFlags; + +[Collection(nameof(WebRequestCollection))] +public class FlagEvaluationTracerLifecycleTests(ITestOutputHelper output) +{ + private readonly ITestOutputHelper _output = output; + + [Fact] + public async Task PublicFlushWaitsForBothTracesAndFlagEvaluations() + { + using var agent = MockTracerAgent.Create(_output); + var received = new ConcurrentQueue(); + agent.EventPlatformProxyPayloadReceived += (_, args) => received.Enqueue(args.Value); + var traceWriter = new Mock(); + traceWriter.Setup(writer => writer.FlushTracesAsync()).Returns(Task.CompletedTask); + traceWriter.Setup(writer => writer.FlushAndCloseAsync()).Returns(Task.CompletedTask); + await using var tracer = TracerHelper.Create(Settings(agent.Port), agentWriter: traceWriter.Object); + var module = tracer.TracerManager.FeatureFlags!; + module.Activate(); + module.EvaluationWriter!.TryEnqueue(Observation()).Should().BeTrue(); + + await tracer.FlushAsync(); + + traceWriter.Verify(writer => writer.FlushTracesAsync(), Times.Once); + received.Should().ContainSingle("public flush must not leave events waiting for the ten-second timer"); + } + + [Fact] + public async Task ShutdownWaitsForBoundedFlagFlushBeforeFinalTelemetry() + { + using var agent = MockTracerAgent.Create(_output); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + agent.EventPlatformProxyPayloadReceived += (_, _) => + { + entered.TrySetResult(true); + release.Task.GetAwaiter().GetResult(); + }; + var traceWriter = new Mock(); + traceWriter.Setup(writer => writer.FlushAndCloseAsync()).Returns(Task.CompletedTask); + var telemetry = new Mock(); + telemetry.Setup(controller => controller.DisposeAsync()).Returns(Task.CompletedTask); + var tracer = TracerHelper.Create(Settings(agent.Port), agentWriter: traceWriter.Object, telemetryController: telemetry.Object); + try + { + var module = tracer.TracerManager.FeatureFlags!; + module.Activate(); + module.EvaluationWriter!.TryEnqueue(Observation()).Should().BeTrue(); + var shutdown = tracer.DisposeAsync().AsTask(); + (await Task.WhenAny(entered.Task, Task.Delay(TimeSpan.FromSeconds(2)))).Should().BeSameAs(entered.Task); + telemetry.Verify(controller => controller.DisposeAsync(), Times.Never, "the existing final telemetry send should include metrics available from the bounded drain"); + shutdown.IsCompleted.Should().BeFalse(); + release.TrySetResult(true); + await shutdown; + telemetry.Verify(controller => controller.DisposeAsync(), Times.Once); + } + finally + { + release.TrySetResult(true); + await tracer.DisposeAsync(); + } + } + + private static TracerSettings Settings(int port) => new(new NameValueConfigurationSource(new NameValueCollection + { + [ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSource] = "remote_config", + [ConfigurationKeys.AgentUri] = $"http://127.0.0.1:{port}", + [ConfigurationKeys.ServiceName] = "lifecycle-test", + })); + + private static FlagEvalEvent Observation() => new("flag", "on", null, "private-subject", 1790000000000, null); +} From 3cef2ec0d2364ae93c6f3d544391d9d4327d1e1f Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Wed, 30 Sep 2026 19:29:31 +0000 Subject: [PATCH 07/15] test(ffe): validate native EVP pipeline and performance --- .../FlagEvaluationConfigurationServer.cs | 86 ++++++++ .../FlagEvaluationIntegrationTests.cs | 200 ++++++++++++++++++ .../Tracing/FlagEvaluationBenchmarks.cs | 102 +++++++++ .../EvaluationEventsSample.cs | 68 ++++++ .../EvaluationPerformanceSample.cs | 94 ++++++++ .../Samples.OpenFeature/Program.cs | 19 ++ 6 files changed, 569 insertions(+) create mode 100644 tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/FeatureFlags/FlagEvaluationConfigurationServer.cs create mode 100644 tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/FeatureFlags/FlagEvaluationIntegrationTests.cs create mode 100644 tracer/test/benchmarks/Benchmarks.Trace/Tracing/FlagEvaluationBenchmarks.cs create mode 100644 tracer/test/test-applications/integrations/Samples.OpenFeature/EvaluationEventsSample.cs create mode 100644 tracer/test/test-applications/integrations/Samples.OpenFeature/EvaluationPerformanceSample.cs diff --git a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/FeatureFlags/FlagEvaluationConfigurationServer.cs b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/FeatureFlags/FlagEvaluationConfigurationServer.cs new file mode 100644 index 000000000000..ce5b5cc752c0 --- /dev/null +++ b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/FeatureFlags/FlagEvaluationConfigurationServer.cs @@ -0,0 +1,86 @@ +// +// 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 System.Collections.Concurrent; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Datadog.Trace.TestHelpers; + +namespace Datadog.Trace.ClrProfiler.IntegrationTests.FeatureFlags; + +// Run-owned configuration fixture; never modifies a shared staging configuration. +internal sealed class FlagEvaluationConfigurationServer : IDisposable +{ + private readonly HttpListener _listener = new(); + private readonly Task _loop; + private Tuple _response; + + public FlagEvaluationConfigurationServer(string response) + { + _response = CreateResponse(response); + Url = $"http://127.0.0.1:{TcpPortProvider.GetOpenPort()}/"; + _listener.Prefixes.Add(Url); + _listener.Start(); + _loop = Task.Run(ServeAsync); + } + + public string Url { get; } + + public ConcurrentQueue Requests { get; } = new(); + + public Action AdvanceConfiguration { get; set; } + + public void SetResponse(string response) => Volatile.Write(ref _response, CreateResponse(response)); + + public void Dispose() + { + _listener.Close(); + _loop.GetAwaiter().GetResult(); + } + + private static Tuple CreateResponse(string response) + => Tuple.Create(Encoding.UTF8.GetBytes(response), "\"" + Guid.NewGuid().ToString("N") + "\""); + + private async Task ServeAsync() + { + try + { + while (_listener.IsListening) + { + var context = await _listener.GetContextAsync(); + var path = context.Request.Url.AbsolutePath; + Requests.Enqueue(path); + if (path == "/advance") + { + AdvanceConfiguration?.Invoke(); + } + + var response = Volatile.Read(ref _response); + context.Response.Headers["ETag"] = response.Item2; + if (path != "/advance" && context.Request.Headers["If-None-Match"] == response.Item2) + { + context.Response.StatusCode = 304; + context.Response.Close(); + continue; + } + + var body = response.Item1; + context.Response.ContentType = "application/json"; + context.Response.ContentLength64 = body.Length; + await context.Response.OutputStream.WriteAsync(body, 0, body.Length); + context.Response.Close(); + } + } + catch (HttpListenerException) when (!_listener.IsListening) + { + } + catch (ObjectDisposedException) + { + } + } +} diff --git a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/FeatureFlags/FlagEvaluationIntegrationTests.cs b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/FeatureFlags/FlagEvaluationIntegrationTests.cs new file mode 100644 index 000000000000..ebfef63ebc8a --- /dev/null +++ b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/FeatureFlags/FlagEvaluationIntegrationTests.cs @@ -0,0 +1,200 @@ +// +// 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.Collections.Concurrent; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Datadog.Trace.Configuration; +using Datadog.Trace.FeatureFlags.Rcm.Model; +using Datadog.Trace.RemoteConfigurationManagement; +using Datadog.Trace.TestHelpers; +using Datadog.Trace.Util.Json; +using Datadog.Trace.Vendors.Newtonsoft.Json.Linq; +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; + +namespace Datadog.Trace.ClrProfiler.IntegrationTests.FeatureFlags; + +#if NETFRAMEWORK +[Collection(nameof(ManualInstrumentationTests))] +#endif +public class FlagEvaluationIntegrationTests : TestHelper +{ + private const string Subject = "evp-subject-canary@example.test"; + private const string SubjectHash = "sha256_0f0dd8be0ebb286af88e00cc7581e5afd2e3a0485ad97e8f7275dfa282c8b243"; + private const string AttributeCanary = "evp-private-attribute-canary"; + + public FlagEvaluationIntegrationTests(ITestOutputHelper output) + : base("OpenFeature", output) + { + } + + [SkippableTheory] + [InlineData(false, true, false)] + [InlineData(true, true, false)] + [InlineData(false, false, false)] + [InlineData(true, false, false)] + [InlineData(false, true, true)] + [InlineData(true, true, true)] + [InlineData(false, false, true)] + [InlineData(true, false, true)] + [Trait("RunOnWindows", "True")] + public async Task InstrumentedEvaluationsPreserveResultsAndRespectPrivacy(bool consent, bool enabled, bool agentless) + { + using var agent = EnvironmentHelper.GetMockAgent(); + var configuration = CreateConfiguration(consent); + using var cdn = agentless ? new FlagEvaluationConfigurationServer(Envelope(configuration)) : null; + if (cdn is null) + { + agent.SetupRcm(Output, [((object)configuration, RcmProducts.FfeFlags, nameof(FlagEvaluationIntegrationTests))]); + } + else + { + SetEnvironmentVariable(ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSourceAgentlessBaseUrl, cdn.Url + "configuration"); + } + + var payloads = new ConcurrentQueue(); + agent.EventPlatformProxyPayloadReceived += (_, args) => + { + if (args.Value.PathAndQuery.EndsWith("/flagevaluation")) + { + payloads.Enqueue(args.Value); + } + }; + SetEnvironmentVariable(ConfigurationKeys.Rcm.PollInterval, "0.1"); + SetEnvironmentVariable(ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSource, agentless ? "agentless" : "remote_config"); + SetEnvironmentVariable(ConfigurationKeys.FeatureFlags.FlaggingEvaluationCountsEnabled, enabled ? "true" : "false"); + SetEnvironmentVariable(ConfigurationKeys.ApiKey, "not-an-event-credential"); + using var telemetry = this.ConfigureTelemetry(); + using var process = await RunSampleAndWaitForExit(agent, arguments: "evp"); + process.StandardOutput.Should().Contain(""); + process.StandardOutput.Should().Contain(""); + + if (!enabled) + { + payloads.Should().BeEmpty("the kill switch disables events, not evaluations"); + return; + } + + payloads.Should().NotBeEmpty("the real native profiler must connect all provider stubs"); + foreach (var payload in payloads) + { + payload.PathAndQuery.Should().Be("/evp_proxy/v2/api/v2/flagevaluation"); + payload.Headers["Content-Encoding"].Should().Be("gzip"); + payload.Headers["Content-Type"].Should().Be("application/json"); + payload.Headers["X-Datadog-EVP-Subdomain"].Should().Be("event-platform-intake"); + payload.Headers["DD-EVP-ORIGIN"].Should().Be("dd-trace-dotnet"); + payload.Headers["DD-EVP-ORIGIN-VERSION"].Should().Be(TracerConstants.ThreePartVersion); + payload.Headers["DD-API-KEY"].Should().BeNull(); + payload.Headers["Authorization"].Should().BeNull(); + // BodyInJson is the decompressed UTF-8 request body, before parsing/reserializing JSON. + if (!consent) + { + payload.BodyInJson.Should().NotContain(Subject).And.NotContain(AttributeCanary); + } + } + + var rows = payloads.SelectMany(p => (JArray)JObject.Parse(p.BodyInJson)["flagEvaluations"]).ToList(); + rows.Sum(r => (long)r["evaluation_count"]).Should().Be(6); + rows.Where(r => (string)r["flag"]["key"] == "simple-string" && r["error"] is null).Sum(r => (long)r["evaluation_count"]).Should().Be(3); + rows.Should().Contain(r => (string)r["flag"]["key"] == "exposure-flag", "DoLog must not gate this event track"); + rows.Select(r => (string)r["error"]?["message"]).Should().Contain("FLAG_NOT_FOUND").And.Contain("TYPE_MISMATCH"); + foreach (var row in rows) + { + ((string)row["targeting_key"]).Should().Be(consent ? Subject : SubjectHash); + if (consent) + { + ((string)row["context"]["evaluation"]["privateAttribute"]).Should().Be(AttributeCanary); + } + else + { + row["context"]?["evaluation"].Should().BeNull(); + } + + if (row["error"] is not null) + { + ((bool)row["runtime_default_used"]).Should().BeTrue(); + } + else + { + row["runtime_default_used"].Should().BeNull(); + } + } + } + + [SkippableTheory] + [InlineData(false)] + [InlineData(true)] + [Trait("RunOnWindows", "True")] + public async Task ConfigurationChangeBeforeFlushPreservesEachEvaluationsConsent(bool initialConsent) + { + using var agent = EnvironmentHelper.GetMockAgent(); + using var cdn = new FlagEvaluationConfigurationServer(Envelope(CreateConfiguration(initialConsent))); + var payloads = new ConcurrentQueue(); + agent.EventPlatformProxyPayloadReceived += (_, args) => + { + if (args.Value.PathAndQuery.EndsWith("/flagevaluation")) + { + payloads.Enqueue(args.Value.BodyInJson); + } + }; + var flushedBeforeSwap = false; + cdn.AdvanceConfiguration = () => + { + flushedBeforeSwap = !payloads.IsEmpty; + cdn.SetResponse(Envelope(CreateConfiguration(!initialConsent))); + }; + SetEnvironmentVariable(ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSource, "agentless"); + SetEnvironmentVariable(ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSourceAgentlessBaseUrl, cdn.Url + "configuration"); + SetEnvironmentVariable(ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSourceAgentlessPollIntervalSeconds, "1"); + SetEnvironmentVariable("FFE_TEST_ADVANCE_URL", cdn.Url + "advance"); + using var telemetry = this.ConfigureTelemetry(); + using var process = await RunSampleAndWaitForExit(agent, arguments: "evp"); + process.StandardOutput.Should().Contain(""); + process.StandardOutput.Should().Contain(""); + flushedBeforeSwap.Should().BeFalse("this test must replace configuration before the pending events are flushed"); + var rows = payloads.SelectMany(p => (JArray)JObject.Parse(p)["flagEvaluations"]).ToList(); + rows.Sum(r => (long)r["evaluation_count"]).Should().Be(7); + var successful = rows.Where(r => (string)r["flag"]["key"] == "simple-string" && r["error"] is null).ToList(); + successful.Where(r => (string)r["targeting_key"] == Subject).Sum(r => (long)r["evaluation_count"]).Should().Be(initialConsent ? 3 : 1); + successful.Where(r => (string)r["targeting_key"] == SubjectHash).Sum(r => (long)r["evaluation_count"]).Should().Be(initialConsent ? 1 : 3); + foreach (var row in rows.Where(r => (string)r["targeting_key"] == SubjectHash)) + { + row["context"]?["evaluation"].Should().BeNull(); + } + } + + [SkippableFact] + [Trait("RunOnWindows", "True")] + public async Task UnavailableAgentDoesNotChangeCdnEvaluationsOrIntroduceFallback() + { + using var agent = EnvironmentHelper.GetMockAgent(); + using var cdn = new FlagEvaluationConfigurationServer(Envelope(CreateConfiguration(false))); + SetEnvironmentVariable(ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSource, "agentless"); + SetEnvironmentVariable(ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSourceAgentlessBaseUrl, cdn.Url + "configuration"); + SetEnvironmentVariable("DD_TRACE_AGENT_URL", $"http://127.0.0.1:{TcpPortProvider.GetOpenPort()}"); + SetEnvironmentVariable(ConfigurationKeys.ApiKey, "not-an-event-credential"); + using var telemetry = this.ConfigureTelemetry(); + var elapsed = Stopwatch.StartNew(); + using var process = await RunSampleAndWaitForExit(agent, arguments: "evp"); + process.StandardOutput.Should().Contain("").And.Contain(""); + elapsed.Elapsed.TotalSeconds.Should().BeLessThan(15, "a refused Agent must not block shutdown indefinitely"); + cdn.Requests.Should().NotBeEmpty().And.OnlyContain(path => path == "/configuration", "the configuration endpoint is not an event fallback"); + } + + private static ServerConfiguration CreateConfiguration(bool consent) => new() + { + ObserveFullEvaluationData = consent, + CreatedAt = consent ? "2026-09-30T00:00:01Z" : "2026-09-30T00:00:00Z", + Format = "SERVER", + Environment = new() { Name = "test" }, + Flags = FeatureFlagsHelpers.CreateAllFlags(), + }; + + private static string Envelope(ServerConfiguration configuration) + => JsonHelper.SerializeObject(new { data = new { type = "universal-flag-configuration", attributes = configuration } }); +} diff --git a/tracer/test/benchmarks/Benchmarks.Trace/Tracing/FlagEvaluationBenchmarks.cs b/tracer/test/benchmarks/Benchmarks.Trace/Tracing/FlagEvaluationBenchmarks.cs new file mode 100644 index 000000000000..77683890c638 --- /dev/null +++ b/tracer/test/benchmarks/Benchmarks.Trace/Tracing/FlagEvaluationBenchmarks.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; +using Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation.OpenFeature; +using Datadog.Trace.FeatureFlags.FlagEvaluation; +using Datadog.Trace.Telemetry; + +namespace Benchmarks.Trace; + +// Component measurement only: provider hooks and native CallTarget are measured separately +// with the instrumented OpenFeature sample. No network or fixture construction is timed here. +[MemoryDiagnoser] +public class FlagEvaluationBenchmarks +{ + private const int BatchSize = 256; + private FlagEvaluationWriter _writer; + private Dictionary _attributes; + private string[] _subjects; + private bool _consent; + private TaskCompletionSource _release; + + [Params("Disabled", "ProtectedRepeated", "ProtectedUnique", "FullRepeated", "FullHostile", "Saturated")] + public string Scenario { get; set; } + + [GlobalSetup] + public void Setup() + { + _subjects = new string[BatchSize]; + for (var i = 0; i < _subjects.Length; i++) + { + _subjects[i] = "subject-" + i; + } + + _consent = Scenario.StartsWith("Full", StringComparison.Ordinal); + _attributes = new Dictionary(); + var fields = Scenario == "FullHostile" ? 1024 : 8; + for (var i = 0; i < fields; i++) + { + _attributes["field-" + i] = "value"; + } + + if (Scenario == "Disabled") + { + return; + } + + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var context = new Dictionary { ["service"] = "ffe-benchmark" }; + _writer = new FlagEvaluationWriter( + _ => + { + if (Scenario != "Saturated") + { + return Task.CompletedTask; + } + + entered.TrySetResult(true); + return _release.Task; + }, + () => context, + queueCap: Scenario == "Saturated" ? 1 : 16384, + metrics: NullMetricsTelemetryCollector.Instance); + + if (Scenario == "Saturated") + { + var observation = new FlagEvalEvent("flag", "on", "allocation", "subject", 1, null); + _writer.TryEnqueue(observation); + _ = _writer.FlushAsync(); + if (!entered.Task.Wait(TimeSpan.FromSeconds(5))) + { + throw new InvalidOperationException("Benchmark did not reach blocked-send setup"); + } + + _writer.TryEnqueue(observation); + } + } + + [GlobalCleanup] + public void Cleanup() + { + _release?.TrySetResult(true); + _writer?.CloseAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult(); + } + + [Benchmark(OperationsPerInvoke = BatchSize)] + public Task ObserveAndFlush() + { + for (var i = 0; i < BatchSize; i++) + { + if (_writer is not null && _writer.HasCapacity()) + { + OpenFeatureSdkEnqueueEVPIntegration.Enqueue( + _writer, "flag", "on", "allocation", _subjects[Scenario == "ProtectedUnique" ? i : 0], + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), _consent, null, _attributes, 0); + } + } + + return _writer is null || Scenario == "Saturated" ? Task.CompletedTask : _writer.FlushAsync(); + } +} diff --git a/tracer/test/test-applications/integrations/Samples.OpenFeature/EvaluationEventsSample.cs b/tracer/test/test-applications/integrations/Samples.OpenFeature/EvaluationEventsSample.cs new file mode 100644 index 000000000000..5476339e3d13 --- /dev/null +++ b/tracer/test/test-applications/integrations/Samples.OpenFeature/EvaluationEventsSample.cs @@ -0,0 +1,68 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using OpenFeature.Constant; +using OpenFeature.Model; +using Samples; + +namespace Samples.FeatureFlags; + +internal static class EvaluationEventsSample +{ + internal static async Task RunWithoutTracerAsync() + { + await global::OpenFeature.Api.Instance.SetProviderAsync(new Datadog.FeatureFlags.OpenFeature.DatadogProvider()); + var client = global::OpenFeature.Api.Instance.GetClient(); + var context = EvaluationContext.Builder().Set("targetingKey", "evp-subject-canary@example.test").Build(); + var result = await client.GetStringDetailsAsync("simple-string", "caller-default", context); + Check(result.Value == "caller-default" && result.ErrorType == ErrorType.ProviderNotReady, "uninstrumented provider default"); + Console.WriteLine(""); + } + + internal static async Task RunAsync() + { + var client = global::OpenFeature.Api.Instance.GetClient(); + var context = EvaluationContext.Builder() + .Set("targetingKey", "evp-subject-canary@example.test") + .Set("privateAttribute", "evp-private-attribute-canary") + .Build(); + for (var i = 0; i < 3; i++) + { + var result = await client.GetStringDetailsAsync("simple-string", "caller-default", context); + Check(result.Value == "test-value" && result.ErrorType == ErrorType.None, "simple-string result"); + } + + var exposure = await client.GetStringDetailsAsync("exposure-flag", "caller-default", context); + Check(exposure.Value == "tracked-value" && exposure.ErrorType == ErrorType.None, "exposure-flag result"); + var missing = await client.GetStringDetailsAsync("missing-flag", "caller-default", context); + Check(missing.Value == "caller-default" && missing.ErrorType == ErrorType.FlagNotFound, "missing flag default"); + var wrongType = await client.GetBooleanDetailsAsync("simple-string", false, context); + Check(!wrongType.Value && wrongType.ErrorType == ErrorType.TypeMismatch, "wrong type default"); + + Console.WriteLine(""); + var advanceUrl = Environment.GetEnvironmentVariable("FFE_TEST_ADVANCE_URL"); + if (!string.IsNullOrEmpty(advanceUrl)) + { + var changed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Datadog.FeatureFlags.OpenFeature.DatadogProvider.RegisterOnNewConfigEventHandler(() => changed.TrySetResult(true)); + using var http = new HttpClient(); + using var response = await http.GetAsync(advanceUrl); + response.EnsureSuccessStatusCode(); + Check(await Task.WhenAny(changed.Task, Task.Delay(5_000)) == changed.Task, "configuration update notification"); + var afterChange = await client.GetStringDetailsAsync("simple-string", "caller-default", context); + Check(afterChange.Value == "test-value" && afterChange.ErrorType == ErrorType.None, "result after configuration update"); + Console.WriteLine(""); + } + + await SampleHelpers.ForceTracerFlushAsync(); + Console.WriteLine(""); + } + + private static void Check(bool condition, string description) + { + if (!condition) + { + throw new InvalidOperationException("Evaluation assertion failed: " + description); + } + } +} diff --git a/tracer/test/test-applications/integrations/Samples.OpenFeature/EvaluationPerformanceSample.cs b/tracer/test/test-applications/integrations/Samples.OpenFeature/EvaluationPerformanceSample.cs new file mode 100644 index 000000000000..c2d305b1599d --- /dev/null +++ b/tracer/test/test-applications/integrations/Samples.OpenFeature/EvaluationPerformanceSample.cs @@ -0,0 +1,94 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using OpenFeature.Constant; +using OpenFeature.Model; +using Samples; + +namespace Samples.FeatureFlags; + +// Uses only existing public OpenFeature/provider APIs, so the same driver can load +// either baseline or candidate assemblies. Fixture setup and flush are measured separately. +internal static class EvaluationPerformanceSample +{ + internal static async Task RunAsync(double initializationMs) + { + var iterations = int.Parse(Environment.GetEnvironmentVariable("FFE_TEST_ITERATIONS") ?? "10000"); + var subjects = int.Parse(Environment.GetEnvironmentVariable("FFE_TEST_SUBJECTS") ?? "64"); + var workload = Environment.GetEnvironmentVariable("FFE_TEST_WORKLOAD") ?? "burst"; + var client = global::OpenFeature.Api.Instance.GetClient(); + var setup = Stopwatch.StartNew(); + var contexts = new EvaluationContext[subjects]; + for (var i = 0; i < contexts.Length; i++) + { + var builder = EvaluationContext.Builder().Set("targetingKey", "evp-subject-" + i); + var fields = workload == "hostile" ? 1024 : 8; + for (var field = 0; field < fields; field++) + { + builder.Set("attribute-" + field, "evp-private-attribute-canary"); + } + + contexts[i] = builder.Build(); + } + + var fixtureMs = setup.Elapsed.TotalMilliseconds; + var warmup = Stopwatch.StartNew(); + var warmupCount = 0; + while (warmup.Elapsed.TotalSeconds < 3) + { + Evaluate(warmupCount++); + } + + await Task.Delay(250); // allow background tiered compilation to finish before measurement + await SampleHelpers.ForceTracerFlushAsync(); + var latencies = new double[iterations]; + var retainedBefore = GC.GetTotalMemory(forceFullCollection: true); + var allocatedBefore = AllocatedBytes(); + using var process = Process.GetCurrentProcess(); + var cpuBefore = process.TotalProcessorTime; + var elapsed = Stopwatch.StartNew(); + for (var i = 0; i < iterations; i++) + { + var start = Stopwatch.GetTimestamp(); + Evaluate(i); + latencies[i] = (Stopwatch.GetTimestamp() - start) * 1_000_000.0 / Stopwatch.Frequency; + if (workload == "sparse" || (workload == "typical" && i % 10 == 9)) + { + Thread.Sleep(10); + } + } + + var evaluationMs = elapsed.Elapsed.TotalMilliseconds; + var allocatedAfter = AllocatedBytes(); + var flush = Stopwatch.StartNew(); + await SampleHelpers.ForceTracerFlushAsync(); + var flushMs = flush.Elapsed.TotalMilliseconds; + var allocatedIncludingFlush = AllocatedBytes() - allocatedBefore; + var cpuMs = (process.TotalProcessorTime - cpuBefore).TotalMilliseconds; + var retainedAfter = GC.GetTotalMemory(forceFullCollection: true); + process.Refresh(); + Array.Sort(latencies); + Console.WriteLine(FormattableString.Invariant( + $"FFE_PERF {{\"iterations\":{iterations},\"warmup\":{warmupCount},\"subjects\":{subjects},\"workload\":\"{workload}\",\"initialization_ms\":{initializationMs},\"fixture_ms\":{fixtureMs},\"evaluation_ms\":{evaluationMs},\"flush_ms\":{flushMs},\"cpu_ms_including_flush\":{cpuMs},\"p50_us\":{latencies[iterations / 2]},\"p95_us\":{latencies[(int)(iterations * 0.95)]},\"p99_us\":{latencies[(int)(iterations * 0.99)]},\"allocated_bytes\":{allocatedAfter - allocatedBefore},\"allocated_bytes_including_flush\":{allocatedIncludingFlush},\"retained_before_bytes\":{retainedBefore},\"retained_after_bytes\":{retainedAfter},\"working_set_bytes\":{process.WorkingSet64},\"peak_working_set_bytes\":{process.PeakWorkingSet64}}}")); + + void Evaluate(int index) + { + var result = client.GetStringDetailsAsync("simple-string", "caller-default", contexts[index % contexts.Length]).GetAwaiter().GetResult(); + if (result.Value != "test-value" || result.ErrorType != ErrorType.None) + { + throw new InvalidOperationException("Performance driver observed an incorrect evaluation result"); + } + } + } + + private static long AllocatedBytes() + { +#if NETCOREAPP3_0_OR_GREATER + return GC.GetTotalAllocatedBytes(precise: true); +#else + // Execution evidence uses net10.0; retain source compatibility with the older sample TFMs. + return 0; +#endif + } +} diff --git a/tracer/test/test-applications/integrations/Samples.OpenFeature/Program.cs b/tracer/test/test-applications/integrations/Samples.OpenFeature/Program.cs index 7408d1eb7c0d..098c2c045c60 100644 --- a/tracer/test/test-applications/integrations/Samples.OpenFeature/Program.cs +++ b/tracer/test/test-applications/integrations/Samples.OpenFeature/Program.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -19,14 +20,32 @@ private async static Task Main(string[] args) int configUpdates = 0; Evaluator.RegisterOnNewConfigEventHandler(() => Interlocked.Increment(ref configUpdates)); + var initialization = Stopwatch.StartNew(); if (!await Evaluator.Init()) { Console.WriteLine($""); + if (args.Contains("evp-uninstrumented")) + { + await EvaluationEventsSample.RunWithoutTracerAsync(); + } + return; } Console.WriteLine($""); + if (args.Contains("evp-perf")) + { + await EvaluationPerformanceSample.RunAsync(initialization.Elapsed.TotalMilliseconds); + return; + } + + if (args.Contains("evp")) + { + await EvaluationEventsSample.RunAsync(); + return; + } + // Init() has awaited InitializeAsync, so a known flag resolves now. If the CallTarget stops // substituting the real task, this prints NOT_READY. var probe = Evaluator.Evaluate("simple-string"); From 501a1d30d710e74d739a213f51a257abcf2cbe5c Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Thu, 1 Oct 2026 19:42:27 +0000 Subject: [PATCH 08/15] [Feature Flags] Guard worker wake and empty flush lifecycle --- .../FeatureFlags/FlagEvaluationWriterTests.cs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationWriterTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationWriterTests.cs index eb14f1f733e4..2b773530f2ae 100644 --- a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationWriterTests.cs +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationWriterTests.cs @@ -377,6 +377,80 @@ public async Task FlushDuringBlockedSendWaitsForTheNextBatchAndCoalescesCallers( } } + [Fact] + public async Task RepeatedIdleWakeCyclesPreserveEveryObservationAndFlush() + { + long delivered = 0; + var writer = new FlagEvaluationWriter( + bytes => + { + var rows = JObject.Parse(Encoding.UTF8.GetString(Decompress(bytes)))["flagEvaluations"]!; + Interlocked.Add(ref delivered, rows.Sum(row => row["evaluation_count"]!.Value())); + return Task.CompletedTask; + }, + Context, + queueCap: 1, + flushInterval: TimeSpan.FromHours(1)); + try + { + for (var cycle = 0; cycle < 50; cycle++) + { + // Exercise both work arriving around a wait and a worker already asleep. + if (cycle % 2 == 0) + { + await Task.Delay(1); + } + + writer.TryEnqueue(Observation()).Should().BeTrue(); + SpinWait.SpinUntil(writer.HasCapacity, TimeSpan.FromSeconds(5)).Should().BeTrue( + "enqueue must wake the idle worker without an explicit flush or timer"); + await Completes(writer.FlushAsync()); + Volatile.Read(ref delivered).Should().Be(cycle + 1); + await Completes(writer.FlushAsync()); + Volatile.Read(ref delivered).Should().Be(cycle + 1, "empty flushes must not resend observations"); + } + } + finally + { + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + } + } + + [Fact] + public async Task EmptyFlushRequestedDuringSendCompletesWithoutWaitingForTimer() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + var writer = new FlagEvaluationWriter( + _ => + { + Interlocked.Increment(ref calls); + entered.TrySetResult(true); + return release.Task; + }, + Context, + flushInterval: TimeSpan.FromHours(1)); + try + { + writer.TryEnqueue(Observation()).Should().BeTrue(); + var first = writer.FlushAsync(); + await Completes(entered.Task); + var next = writer.FlushAsync(); + next.Should().NotBeSameAs(first); + next.IsCompleted.Should().BeFalse(); + release.TrySetResult(true); + await Completes(next); + await Completes(first); + Volatile.Read(ref calls).Should().Be(1); + } + finally + { + release.TrySetResult(true); + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + } + } + [Fact] public async Task ContinuousProducersDoNotStarvePeriodicFlushOrClose() { From 02b6a036988bf305f4c297e9c9166c1078ff4af4 Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Thu, 1 Oct 2026 20:09:06 +0000 Subject: [PATCH 09/15] [Feature Flags] Isolate EVP worker context and preserve error codes --- .../FlagEvalEVPHook.cs | 15 ++++ .../FlagEvaluation/FlagEvaluationWriter.cs | 14 +++- .../FlagEvalEVPHookTests.cs | 80 ++++++++++++++++++- .../FeatureFlags/FlagEvaluationWriterTests.cs | 48 +++++++++++ 4 files changed, 155 insertions(+), 2 deletions(-) diff --git a/tracer/src/Datadog.FeatureFlags.OpenFeature/FlagEvalEVPHook.cs b/tracer/src/Datadog.FeatureFlags.OpenFeature/FlagEvalEVPHook.cs index 530c4d3c8307..873836fb85b5 100644 --- a/tracer/src/Datadog.FeatureFlags.OpenFeature/FlagEvalEVPHook.cs +++ b/tracer/src/Datadog.FeatureFlags.OpenFeature/FlagEvalEVPHook.cs @@ -61,6 +61,13 @@ public override ValueTask FinallyAsync(HookContext context, FlagEvaluation ? (long)timestamp.Value : DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); var errorCode = ToErrorCode(details.ErrorType); + if (errorCode is null) + { + // Some evaluator failures carry descriptive messages that the provider does not + // map to an enum. Use only their stable metadata code, never the message. + errorCode = ToMetadataErrorCode(metadata?.GetString("errorCode")); + } + var targetingKey = context.EvaluationContext?.TargetingKey; IReadOnlyDictionary? attributes = null; var omissionReasons = 0; @@ -94,6 +101,14 @@ public override ValueTask FinallyAsync(HookContext context, FlagEvaluation return default; } + private static string? ToMetadataErrorCode(string? code) => code switch + { + null or "" => null, + "FLAG_NOT_FOUND" or "INVALID_CONTEXT" or "PARSE_ERROR" or "PROVIDER_FATAL" or + "PROVIDER_NOT_READY" or "TARGETING_KEY_MISSING" or "TYPE_MISMATCH" or "GENERAL" => code, + _ => "GENERAL", + }; + private static string? ToErrorCode(ErrorType error) => error switch { ErrorType.None => null, diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationWriter.cs b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationWriter.cs index 358892fb676a..c8dd5bd094d8 100644 --- a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationWriter.cs +++ b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationWriter.cs @@ -58,7 +58,19 @@ internal FlagEvaluationWriter( _queue = new BoundedConcurrentQueue(queueCap); _aggregator = new FlagEvaluationAggregator(globalCap, perFlagCap, degradedCap); _telemetry = new FlagEvaluationTelemetry(metrics ?? TelemetryFactory.Metrics); - _consumer = Task.Factory.StartNew(ProcessLoop, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + // Provider initialization can run inside a request. Do not retain its ambient state + // for the lifetime of this worker, or change an already-suppressed caller context. + if (ExecutionContext.IsFlowSuppressed()) + { + _consumer = Task.Factory.StartNew(ProcessLoop, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + else + { + using (ExecutionContext.SuppressFlow()) + { + _consumer = Task.Factory.StartNew(ProcessLoop, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + } } internal bool HasCapacity() diff --git a/tracer/test/Datadog.FeatureFlags.OpenFeature.Tests/FlagEvalEVPHookTests.cs b/tracer/test/Datadog.FeatureFlags.OpenFeature.Tests/FlagEvalEVPHookTests.cs index 731f2fcf02db..30e65804be84 100644 --- a/tracer/test/Datadog.FeatureFlags.OpenFeature.Tests/FlagEvalEVPHookTests.cs +++ b/tracer/test/Datadog.FeatureFlags.OpenFeature.Tests/FlagEvalEVPHookTests.cs @@ -20,6 +20,64 @@ namespace Datadog.FeatureFlags.OpenFeature.Tests; public class FlagEvalEVPHookTests { + [Theory] + [InlineData("GENERAL")] + [InlineData("PARSE_ERROR")] + public async Task EvaluatorErrorMetadataReachesEventsWithoutChangingProviderResult(string code) + { + var fixture = new Fixture(); + // Evaluator failures carry a descriptive message separately from their stable code. + var resolution = FeatureFlagsSdk.GetResolutionDetails("flag", false, new FailedEvaluation(code), 1234); + var details = new FlagEvaluationDetails( + resolution.FlagKey, + resolution.Value, + resolution.ErrorType, + resolution.Reason, + resolution.Variant, + resolution.ErrorMessage, + resolution.FlagMetadata); + + await fixture.Hook.FinallyAsync(Context(), details); + + resolution.Value.Should().BeFalse(); + resolution.ErrorType.Should().Be(ErrorType.None, "the EVP fix must not change existing provider results"); + resolution.Reason.Should().Be("error"); + resolution.ErrorMessage.Should().Be("private-evaluator-error"); + fixture.Enqueues.Should().Be(1); + fixture.ErrorCode.Should().Be(code); + } + + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData("FLAG_NOT_FOUND", "FLAG_NOT_FOUND")] + [InlineData("INVALID_CONTEXT", "INVALID_CONTEXT")] + [InlineData("PROVIDER_FATAL", "PROVIDER_FATAL")] + [InlineData("PROVIDER_NOT_READY", "PROVIDER_NOT_READY")] + [InlineData("TARGETING_KEY_MISSING", "TARGETING_KEY_MISSING")] + [InlineData("TYPE_MISMATCH", "TYPE_MISMATCH")] + [InlineData("private-evaluator-error", "GENERAL")] + [InlineData(123, null)] + public async Task MetadataErrorFallbackEmitsOnlyStableCodes(object? metadataError, string? expected) + { + var fixture = new Fixture(); + + await fixture.Hook.FinallyAsync(Context(), Details(false, metadataError: metadataError)); + + fixture.Enqueues.Should().Be(1); + fixture.ErrorCode.Should().Be(expected); + } + + [Fact] + public async Task ExplicitErrorTypeTakesPrecedenceOverMetadata() + { + var fixture = new Fixture(); + + await fixture.Hook.FinallyAsync(Context(), Details(false, error: ErrorType.TypeMismatch, metadataError: "GENERAL")); + + fixture.ErrorCode.Should().Be("TYPE_MISMATCH"); + } + [Fact] public async Task CapacityRejectionPrecedesSnapshot() { @@ -222,7 +280,7 @@ public async Task UninstrumentedProviderRegistersInertHook() new Metadata("provider"), EvaluationContext.Builder().Set("targetingKey", "subject").Set("country", "US").Build()); - private static FlagEvaluationDetails Details(object? consent, string? variant = "on", ErrorType error = ErrorType.None, object? timestamp = null) + private static FlagEvaluationDetails Details(object? consent, string? variant = "on", ErrorType error = ErrorType.None, object? timestamp = null, object? metadataError = null) { var metadata = new Dictionary { ["__dd_allocation_key"] = "allocation" }; if (consent is not null) @@ -235,9 +293,29 @@ private static FlagEvaluationDetails Details(object? consent, string? vari metadata[FeatureFlagMetadataKeys.EvaluationTimestampMs] = timestamp; } + if (metadataError is not null) + { + metadata["errorCode"] = metadataError; + } + return new FlagEvaluationDetails("flag", true, error, "static", variant, "pii-error-canary", new ImmutableMetadata(metadata)); } + private sealed class FailedEvaluation(string errorCode) : IEvaluation + { + public string FlagKey => "flag"; + + public object Value => false; + + public EvaluationReason Reason => EvaluationReason.Error; + + public string? Variant => null; + + public string? Error => "private-evaluator-error"; + + public IDictionary? FlagMetadata => new Dictionary { ["errorCode"] = errorCode }; + } + private sealed class Fixture { public Fixture() diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationWriterTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationWriterTests.cs index 2b773530f2ae..ce49e39a076a 100644 --- a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationWriterTests.cs +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationWriterTests.cs @@ -377,6 +377,27 @@ public async Task FlushDuringBlockedSendWaitsForTheNextBatchAndCoalescesCallers( } } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task IdleWorkerDoesNotRetainCallerExecutionContext(bool alreadySuppressed) + { + var writer = CreateWriterWithRequestState(alreadySuppressed, out var requestState); + try + { + // Complete a worker turn without closing it, then check retention while it is idle. + await Completes(writer.FlushAsync()); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + requestState.IsAlive.Should().BeFalse("the background writer must not retain the request that initialized it"); + } + finally + { + await writer.CloseAsync(TimeSpan.FromSeconds(2)); + } + } + [Fact] public async Task RepeatedIdleWakeCyclesPreserveEveryObservationAndFlush() { @@ -627,6 +648,33 @@ public async Task PayloadLimitMetricsPreserveEvaluationUnitsAndGoodNeighbors(boo } } + [MethodImpl(MethodImplOptions.NoInlining)] + private static FlagEvaluationWriter CreateWriterWithRequestState(bool alreadySuppressed, out WeakReference requestState) + { + var ambient = new AsyncLocal { Value = new object() }; + requestState = new WeakReference(ambient.Value); + try + { + if (alreadySuppressed) + { + using (ExecutionContext.SuppressFlow()) + { + var writer = new FlagEvaluationWriter(_ => Task.CompletedTask, Context); + ExecutionContext.IsFlowSuppressed().Should().BeTrue("writer construction must preserve caller suppression"); + return writer; + } + } + + var result = new FlagEvaluationWriter(_ => Task.CompletedTask, Context); + ExecutionContext.IsFlowSuppressed().Should().BeFalse("writer construction must restore caller context flow"); + return result; + } + finally + { + ambient.Value = null; + } + } + [MethodImpl(MethodImplOptions.NoInlining)] private static WeakReference EnqueueTrackedObservation(FlagEvaluationWriter writer) { From b1ccab3b78cccf4d4a820b08b42044dc1823f809 Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Thu, 1 Oct 2026 21:56:53 +0000 Subject: [PATCH 10/15] [Feature Flags] Enable nullable checking in EVP helper files --- tracer/missing-nullability-files.csv | 2 -- .../FeatureFlags/FlagEvaluation/ContextOmissionReason.cs | 2 ++ .../FlagEvaluation/FlagEvaluationAgentHeaderHelper.cs | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tracer/missing-nullability-files.csv b/tracer/missing-nullability-files.csv index 12d8ae0e0af3..113080c920ec 100644 --- a/tracer/missing-nullability-files.csv +++ b/tracer/missing-nullability-files.csv @@ -291,8 +291,6 @@ src/Datadog.Trace/Debugger/Snapshots/SnapshotPruner.cs src/Datadog.Trace/Debugger/Snapshots/SnapshotSerializerFieldsAndPropsSelector.cs src/Datadog.Trace/Debugger/Snapshots/SnapshotSummary.cs src/Datadog.Trace/Debugger/Snapshots/TaskSnapshotSerializerFieldsAndPropsSelector.cs -src/Datadog.Trace/FeatureFlags/FlagEvaluation/ContextOmissionReason.cs -src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAgentHeaderHelper.cs src/Datadog.Trace/Headers/Ip/ExtractedHeadersAndIpInfos.cs src/Datadog.Trace/Headers/Ip/IpInfo.cs src/Datadog.Trace/HttpOverStreams/HttpContent/BufferContent.cs diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/ContextOmissionReason.cs b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/ContextOmissionReason.cs index 9e8d3096fd26..08456805032b 100644 --- a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/ContextOmissionReason.cs +++ b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/ContextOmissionReason.cs @@ -3,6 +3,8 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. // +#nullable enable + using System; namespace Datadog.Trace.FeatureFlags.FlagEvaluation; diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAgentHeaderHelper.cs b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAgentHeaderHelper.cs index b71dde839f02..15542212c82f 100644 --- a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAgentHeaderHelper.cs +++ b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAgentHeaderHelper.cs @@ -3,6 +3,8 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. // +#nullable enable + using System.Collections.Generic; using Datadog.Trace.HttpOverStreams; From 071dabf019cea8955af8b38e7efaf428952737de Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Thu, 1 Oct 2026 22:23:01 +0000 Subject: [PATCH 11/15] [Feature Flags] Attach privacy consent directly to evaluation returns --- .../FeatureFlags/FeatureFlagsEvaluator.cs | 371 +++++++++--------- 1 file changed, 183 insertions(+), 188 deletions(-) diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsEvaluator.cs b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsEvaluator.cs index 48072d1f4ed3..84032427e8c5 100644 --- a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsEvaluator.cs +++ b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsEvaluator.cs @@ -56,7 +56,189 @@ public Evaluation Evaluate(string flagKey, ValueType resultType, object? default { // Capture before evaluating: even an exception after a configuration update keeps this consent. var consent = _config?.GetEvaluationConsent(flagKey) == true; - return EvaluateCore(flagKey, resultType, defaultValue, context).WithPrivacyConsent(consent); + try + { + var config = _config; + if (config == null) + { + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Error, + error: "PROVIDER_NOT_READY", + metadata: new Dictionary + { + ["errorCode"] = "PROVIDER_NOT_READY" + }).WithPrivacyConsent(consent); + } + + Flag? flag = null; + var lookupResult = config.Flags?.Find(flagKey, out flag) ?? FlagLookupResult.NotFound; + if (lookupResult == FlagLookupResult.NotFound) + { + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Error, + error: "FLAG_NOT_FOUND", + metadata: new Dictionary + { + ["errorCode"] = "FLAG_NOT_FOUND" + }).WithPrivacyConsent(consent); + } + + if (lookupResult == FlagLookupResult.Invalid || flag is null) + { + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Error, + error: "PARSE_ERROR", + metadata: new Dictionary + { + ["errorCode"] = "PARSE_ERROR" + }).WithPrivacyConsent(consent); + } + + if (flag.Enabled != true) + { + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Disabled).WithPrivacyConsent(consent); + } + + if (flag.VariationType != resultType) + { + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Error, + error: "TYPE_MISMATCH", + metadata: new Dictionary + { + ["errorCode"] = "TYPE_MISMATCH" + }).WithPrivacyConsent(consent); + } + + if (flag.Allocations is null or { Count: 0 }) + { + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Default).WithPrivacyConsent(consent); + } + + var now = DateTime.UtcNow; + var targetingKey = context?.TargetingKey; + + foreach (var allocation in flag.Allocations) + { + if (!IsAllocationActive(allocation, now)) + { + continue; + } + + // Track whether this allocation has targeting rules + var hadRules = allocation.Rules is { Count: > 0 }; + if (hadRules) + { + if (!EvaluateRules(allocation.Rules!, context)) + { + continue; + } + } + + if (allocation.Splits is { Count: > 0 }) + { + foreach (var split in allocation.Splits) + { + if (StringUtil.IsNullOrEmpty(split.VariationKey)) + { + throw new FormatException($"Empty variation key in allocation {allocation.Key}"); + } + + var allShardsMatch = true; + var hadShards = split.Shards is { Count: > 0 }; + if (hadShards) + { + foreach (var shard in split.Shards!) + { + if (!MatchesShard(shard, targetingKey)) + { + allShardsMatch = false; + break; + } + } + } + + if (allShardsMatch) + { + // Determine reason based on how the flag was resolved. + // - TargetingMatch: Allocation had targeting rules that matched + // - Default: A temporal allocation with one unsharded split matched + // - Split: Resolved via percentage split without targeting rules + // - Static: No rules, no shards - simple static value + var isTemporalDefault = !hadRules && + !hadShards && + allocation.Splits.Count == 1 && + (!StringUtil.IsNullOrEmpty(allocation.StartAt) || + !StringUtil.IsNullOrEmpty(allocation.EndAt)); + var reason = hadRules ? EvaluationReason.TargetingMatch + : isTemporalDefault ? EvaluationReason.Default + : hadShards ? EvaluationReason.Split + : EvaluationReason.Static; + + return ResolveVariant(flagKey, resultType, defaultValue, flag, split, allocation, reason, now, context).WithPrivacyConsent(consent); + } + } + } + } + + // No allocation / split matched – use default + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Default).WithPrivacyConsent(consent); + } + catch (FormatException ex) + { + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Error, + error: "PARSE_ERROR", + metadata: new Dictionary + { + ["errorCode"] = "PARSE_ERROR", + ["message"] = ex.Message + }).WithPrivacyConsent(consent); + } + catch (MissingTargetingKeyException) + { + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Error, + error: "TARGETING_KEY_MISSING", + metadata: new Dictionary + { + ["errorCode"] = "TARGETING_KEY_MISSING" + }).WithPrivacyConsent(consent); + } + catch (Exception ex) + { + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Error, + error: ex.Message, + metadata: new Dictionary + { + ["errorCode"] = "GENERAL", + ["message"] = ex.Message + }).WithPrivacyConsent(consent); + } } private static bool IsAllocationActive(Allocation allocation, DateTime now) @@ -483,193 +665,6 @@ private static double ParseInteger(object value) return result; } - private Evaluation EvaluateCore(string flagKey, ValueType resultType, object? defaultValue, EvaluationContext? context) - { - try - { - var config = _config; - if (config == null) - { - return new Evaluation( - flagKey, - defaultValue, - EvaluationReason.Error, - error: "PROVIDER_NOT_READY", - metadata: new Dictionary - { - ["errorCode"] = "PROVIDER_NOT_READY" - }); - } - - Flag? flag = null; - var lookupResult = config.Flags?.Find(flagKey, out flag) ?? FlagLookupResult.NotFound; - if (lookupResult == FlagLookupResult.NotFound) - { - return new Evaluation( - flagKey, - defaultValue, - EvaluationReason.Error, - error: "FLAG_NOT_FOUND", - metadata: new Dictionary - { - ["errorCode"] = "FLAG_NOT_FOUND" - }); - } - - if (lookupResult == FlagLookupResult.Invalid || flag is null) - { - return new Evaluation( - flagKey, - defaultValue, - EvaluationReason.Error, - error: "PARSE_ERROR", - metadata: new Dictionary - { - ["errorCode"] = "PARSE_ERROR" - }); - } - - if (flag.Enabled != true) - { - return new Evaluation( - flagKey, - defaultValue, - EvaluationReason.Disabled); - } - - if (flag.VariationType != resultType) - { - return new Evaluation( - flagKey, - defaultValue, - EvaluationReason.Error, - error: "TYPE_MISMATCH", - metadata: new Dictionary - { - ["errorCode"] = "TYPE_MISMATCH" - }); - } - - if (flag.Allocations is null or { Count: 0 }) - { - return new Evaluation( - flagKey, - defaultValue, - EvaluationReason.Default); - } - - var now = DateTime.UtcNow; - var targetingKey = context?.TargetingKey; - - foreach (var allocation in flag.Allocations) - { - if (!IsAllocationActive(allocation, now)) - { - continue; - } - - // Track whether this allocation has targeting rules - var hadRules = allocation.Rules is { Count: > 0 }; - if (hadRules) - { - if (!EvaluateRules(allocation.Rules!, context)) - { - continue; - } - } - - if (allocation.Splits is { Count: > 0 }) - { - foreach (var split in allocation.Splits) - { - if (StringUtil.IsNullOrEmpty(split.VariationKey)) - { - throw new FormatException($"Empty variation key in allocation {allocation.Key}"); - } - - var allShardsMatch = true; - var hadShards = split.Shards is { Count: > 0 }; - if (hadShards) - { - foreach (var shard in split.Shards!) - { - if (!MatchesShard(shard, targetingKey)) - { - allShardsMatch = false; - break; - } - } - } - - if (allShardsMatch) - { - // Determine reason based on how the flag was resolved. - // - TargetingMatch: Allocation had targeting rules that matched - // - Default: A temporal allocation with one unsharded split matched - // - Split: Resolved via percentage split without targeting rules - // - Static: No rules, no shards - simple static value - var isTemporalDefault = !hadRules && - !hadShards && - allocation.Splits.Count == 1 && - (!StringUtil.IsNullOrEmpty(allocation.StartAt) || - !StringUtil.IsNullOrEmpty(allocation.EndAt)); - var reason = hadRules ? EvaluationReason.TargetingMatch - : isTemporalDefault ? EvaluationReason.Default - : hadShards ? EvaluationReason.Split - : EvaluationReason.Static; - - return ResolveVariant(flagKey, resultType, defaultValue, flag, split, allocation, reason, now, context); - } - } - } - } - - // No allocation / split matched – use default - return new Evaluation( - flagKey, - defaultValue, - EvaluationReason.Default); - } - catch (FormatException ex) - { - return new Evaluation( - flagKey, - defaultValue, - EvaluationReason.Error, - error: "PARSE_ERROR", - metadata: new Dictionary - { - ["errorCode"] = "PARSE_ERROR", - ["message"] = ex.Message - }); - } - catch (MissingTargetingKeyException) - { - return new Evaluation( - flagKey, - defaultValue, - EvaluationReason.Error, - error: "TARGETING_KEY_MISSING", - metadata: new Dictionary - { - ["errorCode"] = "TARGETING_KEY_MISSING" - }); - } - catch (Exception ex) - { - return new Evaluation( - flagKey, - defaultValue, - EvaluationReason.Error, - error: ex.Message, - metadata: new Dictionary - { - ["errorCode"] = "GENERAL", - ["message"] = ex.Message - }); - } - } - private Evaluation ResolveVariant( string flagKey, ValueType resultType, From 95df9d6ce1e69ad0ca050f17a1645b0e0cfa85d3 Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Fri, 2 Oct 2026 00:44:01 +0000 Subject: [PATCH 12/15] [Feature Flags] Initialize privacy consent directly in evaluation metadata --- .../Datadog.Trace/FeatureFlags/Evaluation.cs | 9 +-- .../FeatureFlags/FeatureFlagsEvaluator.cs | 64 +++++++++++++------ .../FeatureFlags/FeatureFlagsModule.cs | 10 ++- 3 files changed, 53 insertions(+), 30 deletions(-) diff --git a/tracer/src/Datadog.Trace/FeatureFlags/Evaluation.cs b/tracer/src/Datadog.Trace/FeatureFlags/Evaluation.cs index 145fc38465ca..6ea76f781430 100644 --- a/tracer/src/Datadog.Trace/FeatureFlags/Evaluation.cs +++ b/tracer/src/Datadog.Trace/FeatureFlags/Evaluation.cs @@ -27,12 +27,5 @@ internal sealed class Evaluation(string flagKey, object? value, EvaluationReason public string? Error { get; } = error; - public IDictionary? FlagMetadata { get; private set; } = metadata; - - internal Evaluation WithPrivacyConsent(bool consent) - { - var metadata = FlagMetadata ??= new Dictionary(); - metadata[FeatureFlagMetadataKeys.ObserveFullEvaluationData] = consent ? "true" : "false"; - return this; - } + public IDictionary? FlagMetadata { get; } = metadata; } diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsEvaluator.cs b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsEvaluator.cs index 84032427e8c5..f71d801cbb9d 100644 --- a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsEvaluator.cs +++ b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsEvaluator.cs @@ -55,7 +55,7 @@ public FeatureFlagsEvaluator(ReportExposureDelegate? onExposureEvent, ServerConf public Evaluation Evaluate(string flagKey, ValueType resultType, object? defaultValue, EvaluationContext? context) { // Capture before evaluating: even an exception after a configuration update keeps this consent. - var consent = _config?.GetEvaluationConsent(flagKey) == true; + var consent = _config?.GetEvaluationConsent(flagKey) == true ? "true" : "false"; try { var config = _config; @@ -68,8 +68,9 @@ public Evaluation Evaluate(string flagKey, ValueType resultType, object? default error: "PROVIDER_NOT_READY", metadata: new Dictionary { - ["errorCode"] = "PROVIDER_NOT_READY" - }).WithPrivacyConsent(consent); + ["errorCode"] = "PROVIDER_NOT_READY", + [FeatureFlagMetadataKeys.ObserveFullEvaluationData] = consent + }); } Flag? flag = null; @@ -83,8 +84,9 @@ public Evaluation Evaluate(string flagKey, ValueType resultType, object? default error: "FLAG_NOT_FOUND", metadata: new Dictionary { - ["errorCode"] = "FLAG_NOT_FOUND" - }).WithPrivacyConsent(consent); + ["errorCode"] = "FLAG_NOT_FOUND", + [FeatureFlagMetadataKeys.ObserveFullEvaluationData] = consent + }); } if (lookupResult == FlagLookupResult.Invalid || flag is null) @@ -96,8 +98,9 @@ public Evaluation Evaluate(string flagKey, ValueType resultType, object? default error: "PARSE_ERROR", metadata: new Dictionary { - ["errorCode"] = "PARSE_ERROR" - }).WithPrivacyConsent(consent); + ["errorCode"] = "PARSE_ERROR", + [FeatureFlagMetadataKeys.ObserveFullEvaluationData] = consent + }); } if (flag.Enabled != true) @@ -105,7 +108,11 @@ public Evaluation Evaluate(string flagKey, ValueType resultType, object? default return new Evaluation( flagKey, defaultValue, - EvaluationReason.Disabled).WithPrivacyConsent(consent); + EvaluationReason.Disabled, + metadata: new Dictionary + { + [FeatureFlagMetadataKeys.ObserveFullEvaluationData] = consent + }); } if (flag.VariationType != resultType) @@ -117,8 +124,9 @@ public Evaluation Evaluate(string flagKey, ValueType resultType, object? default error: "TYPE_MISMATCH", metadata: new Dictionary { - ["errorCode"] = "TYPE_MISMATCH" - }).WithPrivacyConsent(consent); + ["errorCode"] = "TYPE_MISMATCH", + [FeatureFlagMetadataKeys.ObserveFullEvaluationData] = consent + }); } if (flag.Allocations is null or { Count: 0 }) @@ -126,7 +134,11 @@ public Evaluation Evaluate(string flagKey, ValueType resultType, object? default return new Evaluation( flagKey, defaultValue, - EvaluationReason.Default).WithPrivacyConsent(consent); + EvaluationReason.Default, + metadata: new Dictionary + { + [FeatureFlagMetadataKeys.ObserveFullEvaluationData] = consent + }); } var now = DateTime.UtcNow; @@ -189,7 +201,7 @@ public Evaluation Evaluate(string flagKey, ValueType resultType, object? default : hadShards ? EvaluationReason.Split : EvaluationReason.Static; - return ResolveVariant(flagKey, resultType, defaultValue, flag, split, allocation, reason, now, context).WithPrivacyConsent(consent); + return ResolveVariant(flagKey, resultType, defaultValue, flag, split, allocation, reason, now, context, consent); } } } @@ -199,7 +211,11 @@ public Evaluation Evaluate(string flagKey, ValueType resultType, object? default return new Evaluation( flagKey, defaultValue, - EvaluationReason.Default).WithPrivacyConsent(consent); + EvaluationReason.Default, + metadata: new Dictionary + { + [FeatureFlagMetadataKeys.ObserveFullEvaluationData] = consent + }); } catch (FormatException ex) { @@ -211,8 +227,9 @@ public Evaluation Evaluate(string flagKey, ValueType resultType, object? default metadata: new Dictionary { ["errorCode"] = "PARSE_ERROR", - ["message"] = ex.Message - }).WithPrivacyConsent(consent); + ["message"] = ex.Message, + [FeatureFlagMetadataKeys.ObserveFullEvaluationData] = consent + }); } catch (MissingTargetingKeyException) { @@ -223,8 +240,9 @@ public Evaluation Evaluate(string flagKey, ValueType resultType, object? default error: "TARGETING_KEY_MISSING", metadata: new Dictionary { - ["errorCode"] = "TARGETING_KEY_MISSING" - }).WithPrivacyConsent(consent); + ["errorCode"] = "TARGETING_KEY_MISSING", + [FeatureFlagMetadataKeys.ObserveFullEvaluationData] = consent + }); } catch (Exception ex) { @@ -236,8 +254,9 @@ public Evaluation Evaluate(string flagKey, ValueType resultType, object? default metadata: new Dictionary { ["errorCode"] = "GENERAL", - ["message"] = ex.Message - }).WithPrivacyConsent(consent); + ["message"] = ex.Message, + [FeatureFlagMetadataKeys.ObserveFullEvaluationData] = consent + }); } } @@ -674,7 +693,8 @@ private Evaluation ResolveVariant( Allocation allocation, EvaluationReason reason, DateTime evalTime, - EvaluationContext? context) + EvaluationContext? context, + string consent) { var variationKey = split.VariationKey!; @@ -698,6 +718,7 @@ private Evaluation ResolveVariant( var metadata = new Dictionary { [MetadataAllocationKey] = allocation.Key, + [FeatureFlagMetadataKeys.ObserveFullEvaluationData] = consent, }; // do_log and the split serial id are only consumed by APM span enrichment, so emit them @@ -737,7 +758,8 @@ Evaluation ParseError(string error) metadata: new Dictionary { ["errorCode"] = "PARSE_ERROR", - ["message"] = error + ["message"] = error, + [FeatureFlagMetadataKeys.ObserveFullEvaluationData] = consent }); } } diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsModule.cs b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsModule.cs index d48128e70c22..e4745c1277bf 100644 --- a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsModule.cs +++ b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsModule.cs @@ -338,7 +338,15 @@ internal Evaluation Evaluate(string flagKey, ValueType resultType, object? defau if (evaluator is null) { Log.Debug("FeatureFlagsModule::Evaluate -> Evaluator is null (no config received)"); - return new Evaluation(flagKey, defaultValue, EvaluationReason.Error, null, "PROVIDER_NOT_READY").WithPrivacyConsent(false); + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Error, + error: "PROVIDER_NOT_READY", + metadata: new Dictionary + { + [FeatureFlagMetadataKeys.ObserveFullEvaluationData] = "false" + }); } Log.Debug("FeatureFlagsModule::Evaluate -> Returning Evaluation"); From 7584f5a18c25cda2f2ae228a16b3079e42156ad8 Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Fri, 2 Oct 2026 01:02:12 +0000 Subject: [PATCH 13/15] [Feature Flags] Regenerate trimming descriptor for worker context dependencies --- .../src/Datadog.Trace.Trimming/build/Datadog.Trace.Trimming.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tracer/src/Datadog.Trace.Trimming/build/Datadog.Trace.Trimming.xml b/tracer/src/Datadog.Trace.Trimming/build/Datadog.Trace.Trimming.xml index 8925d38839bb..2cc47d327ddd 100644 --- a/tracer/src/Datadog.Trace.Trimming/build/Datadog.Trace.Trimming.xml +++ b/tracer/src/Datadog.Trace.Trimming/build/Datadog.Trace.Trimming.xml @@ -1182,10 +1182,12 @@ + + From 0fc832573879c72232a37a8fe11d11d2c62ef033 Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Fri, 2 Oct 2026 16:24:11 +0000 Subject: [PATCH 14/15] [Feature Flags] Fix legacy EVP timeout and CI compatibility --- .../Agent/Transports/ApiWebRequest.cs | 10 ++++++++++ .../FlagEvaluationAgentSender.cs | 8 ++++++-- .../FlagEvaluationAgentSenderTests.cs | 14 ++++++++++--- .../FeatureFlags/FlagEvaluationWriterTests.cs | 20 ++++++++++++------- .../Samples.OpenFeature.csproj | 4 ++++ 5 files changed, 44 insertions(+), 12 deletions(-) diff --git a/tracer/src/Datadog.Trace/Agent/Transports/ApiWebRequest.cs b/tracer/src/Datadog.Trace/Agent/Transports/ApiWebRequest.cs index 2b0b11fdefbc..4facef9fbea6 100644 --- a/tracer/src/Datadog.Trace/Agent/Transports/ApiWebRequest.cs +++ b/tracer/src/Datadog.Trace/Agent/Transports/ApiWebRequest.cs @@ -8,6 +8,7 @@ using System.IO.Compression; using System.Net; using System.Text; +using System.Threading; using System.Threading.Tasks; using Datadog.Trace.Logging; using Datadog.Trace.Util; @@ -60,6 +61,15 @@ public async Task PostAsync(ArraySegment bytes, string conte return await FinishAndGetResponse().ConfigureAwait(false); } + public async Task PostAsync(ArraySegment bytes, string contentType, string contentEncoding, TimeSpan timeout) + { + // HttpWebRequest.Timeout does not bound asynchronous requests on .NET Framework. + // Abort the request itself instead of abandoning an in-flight send and its payload. + using var cancellation = new CancellationTokenSource(timeout); + using var registration = cancellation.Token.Register(static state => ((HttpWebRequest)state).Abort(), _request); + return await PostAsync(bytes, contentType, contentEncoding).ConfigureAwait(false); + } + public Task PostAsJsonAsync(T payload, MultipartCompression compression) => PostAsJsonAsync(payload, compression, SerializationHelpers.DefaultJsonSettings); diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAgentSender.cs b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAgentSender.cs index acd78ce4e4c8..752042fd87a8 100644 --- a/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAgentSender.cs +++ b/tracer/src/Datadog.Trace/FeatureFlags/FlagEvaluation/FlagEvaluationAgentSender.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using Datadog.Trace.Agent; +using Datadog.Trace.Agent.Transports; using Datadog.Trace.Configuration; using Datadog.Trace.Logging; @@ -17,6 +18,7 @@ namespace Datadog.Trace.FeatureFlags.FlagEvaluation; internal sealed class FlagEvaluationAgentSender : IDisposable { private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(); + private static readonly TimeSpan TcpTimeout = TimeSpan.FromSeconds(5); private readonly object _settingsLock = new(); private IApiRequestFactory? _factory; @@ -53,7 +55,9 @@ internal async Task SendCompressedAsync(ArraySegment payload) try { var request = factory.Create(factory.GetEndpoint("evp_proxy/v2/api/v2/flagevaluation")); - using var response = await request.PostAsync(payload, "application/json", "gzip").ConfigureAwait(false); + using var response = request is ApiWebRequest webRequest + ? await webRequest.PostAsync(payload, "application/json", "gzip", TcpTimeout).ConfigureAwait(false) + : await request.PostAsync(payload, "application/json", "gzip").ConfigureAwait(false); if (response.StatusCode is < 200 or >= 300) { Log.Debug("FeatureFlags flagevaluation Agent request failed with HTTP status {StatusCode}; dropping this batch without retry.", response.StatusCode); @@ -79,6 +83,6 @@ public void Dispose() private static IApiRequestFactory CreateFactory(ExporterSettings settings) => AgentTransportStrategy.Get( settings, productName: "FeatureFlags flagevaluation", - tcpTimeout: TimeSpan.FromSeconds(5), + tcpTimeout: TcpTimeout, httpHeaderHelper: FlagEvaluationAgentHeaderHelper.Instance); } diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationAgentSenderTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationAgentSenderTests.cs index 691ece1bc997..172bade669f7 100644 --- a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationAgentSenderTests.cs +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationAgentSenderTests.cs @@ -15,6 +15,7 @@ using System.Threading; using System.Threading.Tasks; using Datadog.Trace.Agent; +using Datadog.Trace.Agent.Transports; using Datadog.Trace.Configuration; using Datadog.Trace.FeatureFlags.FlagEvaluation; using Datadog.Trace.HttpOverStreams; @@ -185,8 +186,10 @@ public async Task ExporterChangeUsesNewAgentWithoutInterruptingInFlightSend() received.Should().ContainSingle("exporter changes must not resurrect a disposed sender"); } - [Fact] - public async Task SlowTcpAgentTimesOutWithoutReplayingBatch() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SlowTcpAgentTimesOutWithoutReplayingBatch(bool useLegacyTransport) { using var release = new ManualResetEventSlim(); using var agent = MockTracerAgent.Create(output); @@ -196,7 +199,12 @@ public async Task SlowTcpAgentTimesOutWithoutReplayingBatch() Interlocked.Increment(ref requests); release.Wait(TimeSpan.FromSeconds(20)); }; - using var sender = new FlagEvaluationAgentSender(CreateSettings($"http://127.0.0.1:{agent.Port}").Manager.InitialExporterSettings); + var agentUri = new Uri($"http://127.0.0.1:{agent.Port}"); + // Modern runtimes honor HttpWebRequest.Timeout, unlike .NET Framework's async path. + // Leave that property unset to verify the sender independently bounds legacy requests. + using var sender = useLegacyTransport + ? new FlagEvaluationAgentSender(new ApiWebRequestFactory(agentUri, FlagEvaluationAgentHeaderHelper.Instance.DefaultHeaders)) + : new FlagEvaluationAgentSender(CreateSettings(agentUri.ToString()).Manager.InitialExporterSettings); var elapsed = Stopwatch.StartNew(); var send = sender.SendCompressedAsync(Compress("{}")); try diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationWriterTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationWriterTests.cs index ce49e39a076a..1df04eb7ce0b 100644 --- a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationWriterTests.cs +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FlagEvaluationWriterTests.cs @@ -495,16 +495,22 @@ public async Task ContinuousProducersDoNotStarvePeriodicFlushOrClose() queueCap: 64, flushInterval: TimeSpan.FromMilliseconds(20)); var observation = Observation(); - var producers = Enumerable.Range(0, 4).Select(_ => Task.Run(() => - { - while (!stop.IsCancellationRequested) + // These busy loops must not occupy the pool needed by the test's continuations. + // Keep intake continuous while testing the writer's flush and close behavior. + var producers = Enumerable.Range(0, 4).Select(_ => Task.Factory.StartNew( + () => { - if (writer.TryEnqueue(observation)) + while (!stop.IsCancellationRequested) { - Interlocked.Increment(ref accepted); + if (writer.TryEnqueue(observation)) + { + Interlocked.Increment(ref accepted); + } } - } - })).ToArray(); + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default)).ToArray(); try { await Completes(sent.Task); diff --git a/tracer/test/test-applications/integrations/Samples.OpenFeature/Samples.OpenFeature.csproj b/tracer/test/test-applications/integrations/Samples.OpenFeature/Samples.OpenFeature.csproj index 6fa7f23ba66a..642a2376a9a1 100644 --- a/tracer/test/test-applications/integrations/Samples.OpenFeature/Samples.OpenFeature.csproj +++ b/tracer/test/test-applications/integrations/Samples.OpenFeature/Samples.OpenFeature.csproj @@ -8,6 +8,10 @@ 2.3.0 + + + + From ec912ccaaec0c50d52595c5e658c4bd454aea00a Mon Sep 17 00:00:00 2001 From: Vickie Boettcher Date: Fri, 2 Oct 2026 18:36:03 +0000 Subject: [PATCH 15/15] [Feature Flags] Fix integration fixture shutdown and outage isolation --- .../FlagEvaluationConfigurationServer.cs | 7 +++++-- .../FlagEvaluationIntegrationTests.cs | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/FeatureFlags/FlagEvaluationConfigurationServer.cs b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/FeatureFlags/FlagEvaluationConfigurationServer.cs index ce5b5cc752c0..93eb3a9119f6 100644 --- a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/FeatureFlags/FlagEvaluationConfigurationServer.cs +++ b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/FeatureFlags/FlagEvaluationConfigurationServer.cs @@ -19,6 +19,7 @@ internal sealed class FlagEvaluationConfigurationServer : IDisposable private readonly HttpListener _listener = new(); private readonly Task _loop; private Tuple _response; + private bool _stopping; public FlagEvaluationConfigurationServer(string response) { @@ -39,6 +40,8 @@ public FlagEvaluationConfigurationServer(string response) public void Dispose() { + // On Windows, Close can abort GetContextAsync before IsListening becomes false. + Volatile.Write(ref _stopping, true); _listener.Close(); _loop.GetAwaiter().GetResult(); } @@ -76,10 +79,10 @@ private async Task ServeAsync() context.Response.Close(); } } - catch (HttpListenerException) when (!_listener.IsListening) + catch (HttpListenerException) when (Volatile.Read(ref _stopping)) { } - catch (ObjectDisposedException) + catch (ObjectDisposedException) when (Volatile.Read(ref _stopping)) { } } diff --git a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/FeatureFlags/FlagEvaluationIntegrationTests.cs b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/FeatureFlags/FlagEvaluationIntegrationTests.cs index ebfef63ebc8a..087bb6632cbb 100644 --- a/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/FeatureFlags/FlagEvaluationIntegrationTests.cs +++ b/tracer/test/Datadog.Trace.ClrProfiler.IntegrationTests/FeatureFlags/FlagEvaluationIntegrationTests.cs @@ -173,7 +173,20 @@ public async Task ConfigurationChangeBeforeFlushPreservesEachEvaluationsConsent( public async Task UnavailableAgentDoesNotChangeCdnEvaluationsOrIntroduceFallback() { using var agent = EnvironmentHelper.GetMockAgent(); - using var cdn = new FlagEvaluationConfigurationServer(Envelope(CreateConfiguration(false))); + var configuration = CreateConfiguration(false); + // Exercise flagevaluation delivery failure without unrelated trace/exposure senders + // producing expected connection errors that fail CI's error-log checks. + foreach (var flag in configuration.Flags.ValidFlags) + { + foreach (var allocation in flag.Value.Allocations) + { + allocation.DoLog = false; + } + } + + using var cdn = new FlagEvaluationConfigurationServer(Envelope(configuration)); + SetEnvironmentVariable(ConfigurationKeys.TraceEnabled, "false"); + SetEnvironmentVariable(ConfigurationKeys.FeatureFlags.FlaggingEvaluationCountsEnabled, "true"); SetEnvironmentVariable(ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSource, "agentless"); SetEnvironmentVariable(ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSourceAgentlessBaseUrl, cdn.Url + "configuration"); SetEnvironmentVariable("DD_TRACE_AGENT_URL", $"http://127.0.0.1:{TcpPortProvider.GetOpenPort()}");