Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .azure-pipelines/steps/clone-repo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,15 @@ steps:

- checkout: self
clean: true

- bash: |
git submodule sync --recursive
git submodule update --init --recursive
displayName: initialize submodules
condition: and(succeeded(), not(eq(variables['Agent.OS'], 'Windows_NT')))

- powershell: |
git submodule sync --recursive
git submodule update --init --recursive
displayName: initialize submodules
condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'))
5 changes: 5 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,8 @@ updates:
- "*"
cooldown:
default-days: 2

- package-ecosystem: "gitsubmodule"
directory: "/"
schedule:
interval: "weekly"
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "tracer/test/Datadog.Trace.Tests/FeatureFlags/ffe-system-test-data"]
path = tracer/test/Datadog.Trace.Tests/FeatureFlags/ffe-system-test-data
url = https://github.com/DataDog/ffe-system-test-data.git
68 changes: 55 additions & 13 deletions tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public Evaluation Evaluate(string flagKey, ValueType resultType, object? default
});
}

if (config.Flags is null || !config.Flags.TryGetValue(flagKey, out var flag) || flag is null)
if (config.Flags is null || !config.Flags.TryGetValue(flagKey, out var flag))
{
return new Evaluation(
flagKey,
Expand All @@ -83,6 +83,19 @@ public Evaluation Evaluate(string flagKey, ValueType resultType, object? default
});
}

if (flag is null)
{
return new Evaluation(
flagKey,
defaultValue,
EvaluationReason.Error,
error: "PARSE_ERROR",
metadata: new Dictionary<string, string>
{
["errorCode"] = "PARSE_ERROR"
});
}

if (flag.Enabled != true)
{
return new Evaluation(
Expand Down Expand Up @@ -158,12 +171,18 @@ public Evaluation Evaluate(string flagKey, ValueType resultType, object? default
if (allShardsMatch)
{
// Determine reason based on how the flag was resolved.
// Per the FFE spec, SPLIT takes precedence over TARGETING_MATCH:
// - Split: Resolved via percentage split (shards present)
// - TargetingMatch: Allocation had targeting rules that matched (no shards)
// - 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 reason = hadShards ? EvaluationReason.Split
: hadRules ? EvaluationReason.TargetingMatch
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);
Expand Down Expand Up @@ -302,6 +321,13 @@ private static bool EvaluateCondition(ConditionConfiguration condition, Evaluati
return CompareNumber(attributeValue, condition.Value, (a, b) => a <= b);
case ConditionOperator.LT:
return CompareNumber(attributeValue, condition.Value, (a, b) => a < b);
case ConditionOperator.SEMVER_EQ:
case ConditionOperator.SEMVER_NEQ:
case ConditionOperator.SEMVER_LT:
case ConditionOperator.SEMVER_LTE:
case ConditionOperator.SEMVER_GT:
case ConditionOperator.SEMVER_GTE:
return CompareSemanticVersion(condition.Operator.Value, attributeValue, condition.Value);
default:
throw new FormatException($"Unknown condition operator {condition.Operator.ToString()}");
}
Expand Down Expand Up @@ -371,6 +397,27 @@ private static bool CompareNumber(object attributeValue, object? conditionValue,
return comparator(a, b);
}

private static bool CompareSemanticVersion(ConditionOperator operation, object attributeValue, object? conditionValue)
{
if (!SemanticVersion.TryParse(attributeValue, out var attributeVersion)
|| !SemanticVersion.TryParse(conditionValue, out var comparand))
{
return false;
}

var comparison = attributeVersion!.CompareTo(comparand);
return operation switch
{
ConditionOperator.SEMVER_EQ => comparison == 0,
ConditionOperator.SEMVER_NEQ => comparison != 0,
ConditionOperator.SEMVER_LT => comparison < 0,
ConditionOperator.SEMVER_LTE => comparison <= 0,
ConditionOperator.SEMVER_GT => comparison > 0,
ConditionOperator.SEMVER_GTE => comparison >= 0,
_ => false,
};
}

private static bool MatchesShard(Shard shard, string? targetingKey)
{
if (shard.Ranges is null)
Expand All @@ -391,7 +438,7 @@ private static bool MatchesShard(Shard shard, string? targetingKey)
}

[TestingAndPrivateOnly]
internal static int GetShard(string salt, string? targetingKey, int totalShards)
internal static long GetShard(string salt, string? targetingKey, long totalShards)
{
if (StringUtil.IsNullOrEmpty(targetingKey))
{
Expand Down Expand Up @@ -434,12 +481,7 @@ internal static int GetShard(string salt, string? targetingKey, int totalShards)
// Special case "id": if not present, use targeting key
if (name == "id" && !context.Attributes.ContainsKey(name))
{
if (StringUtil.IsNullOrEmpty(context.TargetingKey))
{
throw new MissingTargetingKeyException();
}

return context.TargetingKey;
return StringUtil.IsNullOrEmpty(context.TargetingKey) ? null : context.TargetingKey;
}

return context.GetAttribute(name);
Expand Down
1 change: 1 addition & 0 deletions tracer/src/Datadog.Trace/FeatureFlags/Rcm/FfeProduct.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public ApplyDetails[] UpdateFromRcm(Dictionary<string, List<RemoteConfiguration>
var serverConfigFile = new NamedRawFile(ffeConfig.Path, ffeConfig.Contents).Deserialize<ServerConfiguration>();
if (serverConfigFile.TypedFile is not null)
{
_serverConfigurations.RemoveAll(x => x.Key == ffeConfig.Path.Path);
_serverConfigurations.Add(new KeyValuePair<string, ServerConfiguration>(ffeConfig.Path.Path, serverConfigFile.TypedFile));
res.Add(ApplyDetails.FromOk(ffeConfig.Path.Path));
apply = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ internal bool MatchesRegex(object attributeValue)
throw new FormatException("Condition value can not be null nor empty");
}

if (pattern.StartsWith("(?u)", StringComparison.Ordinal))
{
pattern = pattern.Substring(4);
}

pattern = pattern.Replace("[:alnum:]", @"\p{L}\p{N}");

try
{
_regex = new Regex(pattern, RegexOptions.Compiled);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,11 @@ internal enum ConditionOperator
NOT_MATCHES,
ONE_OF,
NOT_ONE_OF,
IS_NULL
IS_NULL,
SEMVER_EQ,
SEMVER_NEQ,
SEMVER_LT,
SEMVER_LTE,
SEMVER_GT,
SEMVER_GTE
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// <copyright file="FlagDictionaryJsonConverter.cs" company="Datadog">
// 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.
// </copyright>

#nullable enable

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Datadog.Trace.Vendors.Newtonsoft.Json;
using Datadog.Trace.Vendors.Newtonsoft.Json.Linq;

namespace Datadog.Trace.FeatureFlags.Rcm.Model;

internal sealed class FlagDictionaryJsonConverter : JsonConverter<Dictionary<string, Flag>>
{
public override Dictionary<string, Flag>? ReadJson(
JsonReader reader,
Type objectType,
Dictionary<string, Flag>? existingValue,
bool hasExistingValue,
JsonSerializer serializer)
{
if (reader.TokenType == JsonToken.Null)
{
return null;
}

// A remote-config update is a complete snapshot. Never retain flags from the
// previous dictionary when Newtonsoft asks the converter to reuse an instance.
var result = new Dictionary<string, Flag>();
var flags = JObject.Load(reader);
foreach (var property in flags.Properties())
{
try
{
var flag = property.Value.ToObject<Flag>(serializer);
result[property.Name] = flag is not null && IsValid(flag) ? flag : null!;
}
catch (JsonException)
{
result[property.Name] = null!;
}
}

return result;
}

public override void WriteJson(JsonWriter writer, Dictionary<string, Flag>? value, JsonSerializer serializer)
{
serializer.Serialize(writer, value);
}

private static bool IsValid(Flag flag)
{
if (flag.VariationType is null || flag.Variations is null)
{
return false;
}

if (flag.Variations.Values.Any(variant => variant is null || !MatchesType(Unwrap(variant.Value), flag.VariationType.Value)))
{
return false;
}

if (flag.Allocations is null)
{
return true;
}

foreach (var allocation in flag.Allocations)
{
if (allocation?.Splits is null)
{
return false;
}

foreach (var split in allocation.Splits)
{
if (split?.Shards is null)
{
return false;
}

foreach (var shard in split.Shards)
{
if (shard is null || shard.TotalShards <= 0 || shard.TotalShards > uint.MaxValue || shard.Ranges is null)
{
return false;
}

foreach (var range in shard.Ranges)
{
if (range is null || range.Start < 0 || range.Start >= range.End || range.End > shard.TotalShards)
{
return false;
}
}
}
}

if (allocation.Rules is null)
{
continue;
}

foreach (var rule in allocation.Rules)
{
if (rule?.Conditions is null || rule.Conditions.Any(condition => !IsValid(condition)))
{
return false;
}
}
}

return true;
}

private static bool IsValid(ConditionConfiguration? condition)
{
if (condition?.Operator is null)
{
return false;
}

var value = Unwrap(condition.Value);
switch (condition.Operator.Value)
{
case ConditionOperator.MATCHES:
case ConditionOperator.NOT_MATCHES:
if (value is not string pattern)
{
return false;
}

try
{
_ = new Regex(pattern.StartsWith("(?u)", StringComparison.Ordinal) ? pattern.Substring(4) : pattern);
return true;
}
catch (ArgumentException)
{
return false;
}

case ConditionOperator.LT:
case ConditionOperator.LTE:
case ConditionOperator.GT:
case ConditionOperator.GTE:
return value is sbyte or byte or short or ushort or int or uint or long or ulong or float or double or decimal;
case ConditionOperator.ONE_OF:
case ConditionOperator.NOT_ONE_OF:
return condition.Value is JArray array && array.All(item => Unwrap(item) is string);
case ConditionOperator.IS_NULL:
return value is bool;
case ConditionOperator.SEMVER_EQ:
case ConditionOperator.SEMVER_NEQ:
case ConditionOperator.SEMVER_LT:
case ConditionOperator.SEMVER_LTE:
case ConditionOperator.SEMVER_GT:
case ConditionOperator.SEMVER_GTE:
return SemanticVersion.TryParse(value, out _);
default:
return false;
}
}

private static object? Unwrap(object? value) => value is JValue token ? token.Value : value;

private static bool MatchesType(object? value, ValueType type) => type switch
{
ValueType.Boolean => value is bool,
ValueType.String => value is string,
ValueType.Integer => value is sbyte or byte or short or ushort or int or uint or long or ulong,
ValueType.Numeric => value is sbyte or byte or short or ushort or int or uint or long or ulong or float or double or decimal,
ValueType.Json => true,
_ => false,
};
}
Loading
Loading