diff --git a/packages/Datadog.Unity/Runtime/Flags/FlagAssignments.cs b/packages/Datadog.Unity/Runtime/Flags/FlagAssignments.cs
new file mode 100644
index 00000000..4d2a9af7
--- /dev/null
+++ b/packages/Datadog.Unity/Runtime/Flags/FlagAssignments.cs
@@ -0,0 +1,22 @@
+// Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
+// This product includes software developed at Datadog (https://www.datadoghq.com/).
+// Copyright 2026-Present Datadog, Inc.
+
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+
+namespace Datadog.Unity.Flags
+{
+ /// Keeps the assignment map and its lookup encoding together.
+ internal sealed class FlagAssignments
+ {
+ internal readonly IReadOnlyDictionary Flags;
+ internal readonly FlagKeyObfuscation Obfuscation;
+
+ internal FlagAssignments(Dictionary flags, FlagKeyObfuscation obfuscation = null)
+ {
+ Flags = new ReadOnlyDictionary(new Dictionary(flags));
+ Obfuscation = obfuscation;
+ }
+ }
+}
diff --git a/packages/Datadog.Unity/Runtime/Flags/FlagAssignments.cs.meta b/packages/Datadog.Unity/Runtime/Flags/FlagAssignments.cs.meta
new file mode 100644
index 00000000..a5f4fca7
--- /dev/null
+++ b/packages/Datadog.Unity/Runtime/Flags/FlagAssignments.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 42d0a74081a74ac2a655f5d164a4f630
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/packages/Datadog.Unity/Runtime/Flags/FlagKeyObfuscation.cs b/packages/Datadog.Unity/Runtime/Flags/FlagKeyObfuscation.cs
new file mode 100644
index 00000000..6476fa71
--- /dev/null
+++ b/packages/Datadog.Unity/Runtime/Flags/FlagKeyObfuscation.cs
@@ -0,0 +1,87 @@
+// Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
+// This product includes software developed at Datadog (https://www.datadoghq.com/).
+// Copyright 2026-Present Datadog, Inc.
+
+using System;
+using System.Collections.Generic;
+using System.Security.Cryptography;
+using System.Text;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace Datadog.Unity.Flags
+{
+ /// Public response encoding metadata. This is obfuscation, not encryption.
+ internal sealed class FlagKeyObfuscation
+ {
+ internal const string Scheme = "flag-key-sha256-v1";
+ internal const string Capability = "assignment-encoding-flag-key-256-v1";
+ private static readonly UTF8Encoding StrictUtf8 = new(false, true);
+ private static readonly byte[] Domain = StrictUtf8.GetBytes("datadog.feature-flags.flag-key.v1\0");
+ private readonly byte[] _prefix;
+ private readonly Dictionary _lookupKeys = new(StringComparer.Ordinal);
+ private const int LookupCacheLimit = 1024;
+
+ private FlagKeyObfuscation(string salt)
+ {
+ _prefix = new byte[Domain.Length + 16];
+ Array.Copy(Domain, _prefix, Domain.Length);
+ for (var i = 0; i < 16; i++)
+ _prefix[Domain.Length + i] = Convert.ToByte(salt.Substring(i * 2, 2), 16);
+ }
+
+ internal static FlagKeyObfuscation Read(JToken marker, JToken descriptor)
+ {
+ if ((marker == null || (marker.Type == JTokenType.Boolean && !marker.Value())) && descriptor == null)
+ return null;
+
+ if (marker?.Type != JTokenType.Boolean || !marker.Value() || !(descriptor is JObject metadata))
+ throw new JsonSerializationException("Invalid flag-key obfuscation metadata.");
+
+ if (metadata["scheme"]?.Type != JTokenType.String || metadata["scheme"].Value() != Scheme)
+ throw new JsonSerializationException("Unsupported flag-key obfuscation scheme.");
+
+ var salt = metadata["salt"];
+ if (salt?.Type != JTokenType.String || !IsLowercaseHex(salt.Value(), 32))
+ throw new JsonSerializationException("Flag-key salt must contain 32 lowercase hexadecimal characters.");
+
+ return new FlagKeyObfuscation(salt.Value());
+ }
+
+ internal string Encode(string key)
+ {
+ lock (_lookupKeys)
+ {
+ if (_lookupKeys.TryGetValue(key, out var cached)) return cached;
+ var digest = Hash(key);
+ if (_lookupKeys.Count >= LookupCacheLimit) _lookupKeys.Clear();
+ _lookupKeys[key] = digest;
+ return digest;
+ }
+ }
+
+ private string Hash(string key)
+ {
+ // Reject invalid UTF-16 rather than aliasing a key containing a replacement character.
+ var keyBytes = StrictUtf8.GetBytes(key);
+ var input = new byte[_prefix.Length + keyBytes.Length];
+ Array.Copy(_prefix, input, _prefix.Length);
+ Array.Copy(keyBytes, 0, input, _prefix.Length, keyBytes.Length);
+ using var sha256 = SHA256.Create();
+ var digest = sha256.ComputeHash(input);
+ var result = new StringBuilder(64);
+ foreach (var value in digest)
+ result.Append(value.ToString("x2"));
+ return result.ToString();
+ }
+
+ internal static bool IsLowercaseHex(string value, int length)
+ {
+ if (value == null || value.Length != length) return false;
+ foreach (var character in value)
+ if (!((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f')))
+ return false;
+ return true;
+ }
+ }
+}
diff --git a/packages/Datadog.Unity/Runtime/Flags/FlagKeyObfuscation.cs.meta b/packages/Datadog.Unity/Runtime/Flags/FlagKeyObfuscation.cs.meta
new file mode 100644
index 00000000..3e70beae
--- /dev/null
+++ b/packages/Datadog.Unity/Runtime/Flags/FlagKeyObfuscation.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: a49a3f1210364e179aec939df37d2750
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/packages/Datadog.Unity/Runtime/Flags/FlagsClient.cs b/packages/Datadog.Unity/Runtime/Flags/FlagsClient.cs
index 09483e24..ba98a4e1 100644
--- a/packages/Datadog.Unity/Runtime/Flags/FlagsClient.cs
+++ b/packages/Datadog.Unity/Runtime/Flags/FlagsClient.cs
@@ -124,6 +124,8 @@ public void SetEvaluationContext(FlagsEvaluationContext context, Action on
"Provide a stable, unique targeting key per user (e.g. user ID).");
}
+ // A failed or unsupported response must not retain another subject's assignments.
+ _repository.PrepareContext(context);
TransitionState(FlagsClientState.Reconciling);
_fetcher.Fetch(context, flags =>
diff --git a/packages/Datadog.Unity/Runtime/Flags/FlagsRepository.cs b/packages/Datadog.Unity/Runtime/Flags/FlagsRepository.cs
index 27aa8409..135e269d 100644
--- a/packages/Datadog.Unity/Runtime/Flags/FlagsRepository.cs
+++ b/packages/Datadog.Unity/Runtime/Flags/FlagsRepository.cs
@@ -3,6 +3,7 @@
// Copyright 2025-Present Datadog, Inc.
using System.Collections.Generic;
+using System.Text;
namespace Datadog.Unity.Flags
{
@@ -12,7 +13,7 @@ namespace Datadog.Unity.Flags
internal class FlagsRepository
{
private readonly object _lock = new();
- private Dictionary _flags = new();
+ private FlagAssignments _assignments = new(new Dictionary());
private FlagsEvaluationContext _context;
///
@@ -36,7 +37,17 @@ public FlagAssignment GetFlagAssignment(string key)
{
lock (_lock)
{
- _flags.TryGetValue(key, out var flag);
+ if (key == null) return null;
+ string lookupKey;
+ try
+ {
+ lookupKey = _assignments.Obfuscation?.Encode(key) ?? key;
+ }
+ catch (EncoderFallbackException)
+ {
+ return null;
+ }
+ _assignments.Flags.TryGetValue(lookupKey, out var flag);
return flag;
}
}
@@ -45,14 +56,40 @@ public FlagAssignment GetFlagAssignment(string key)
/// Sets the flags and context atomically.
///
public void SetFlagsAndContext(FlagsEvaluationContext context, Dictionary flags)
+ {
+ SetFlagsAndContext(context, new FlagAssignments(flags ?? new Dictionary()));
+ }
+
+ public void SetFlagsAndContext(FlagsEvaluationContext context, FlagAssignments assignments)
{
lock (_lock)
{
_context = context;
- _flags = flags ?? new Dictionary();
+ _assignments = assignments;
}
}
+ /// Keep fallback assignments only when the complete requested context matches.
+ public void PrepareContext(FlagsEvaluationContext context)
+ {
+ lock (_lock)
+ {
+ if (ContextsMatch(_context, context)) return;
+ _context = context;
+ _assignments = new FlagAssignments(new Dictionary());
+ }
+ }
+
+ private static bool ContextsMatch(FlagsEvaluationContext left, FlagsEvaluationContext right)
+ {
+ if (left == null || right == null || left.TargetingKey != right.TargetingKey || left.Attributes.Count != right.Attributes.Count)
+ return false;
+ foreach (var attribute in left.Attributes)
+ if (!right.Attributes.TryGetValue(attribute.Key, out var value) || value != attribute.Value)
+ return false;
+ return true;
+ }
+
///
/// Returns true if any flags are cached.
///
@@ -60,18 +97,18 @@ public bool HasFlags()
{
lock (_lock)
{
- return _flags.Count > 0;
+ return _assignments.Flags.Count > 0;
}
}
///
/// Returns a snapshot of all cached flags.
///
- public Dictionary GetFlagsSnapshot()
+ public FlagAssignments GetFlagsSnapshot()
{
lock (_lock)
{
- return new Dictionary(_flags);
+ return _assignments;
}
}
}
diff --git a/packages/Datadog.Unity/Runtime/Flags/PrecomputeAssignmentsFetcher.cs b/packages/Datadog.Unity/Runtime/Flags/PrecomputeAssignmentsFetcher.cs
index c460794c..2b793057 100644
--- a/packages/Datadog.Unity/Runtime/Flags/PrecomputeAssignmentsFetcher.cs
+++ b/packages/Datadog.Unity/Runtime/Flags/PrecomputeAssignmentsFetcher.cs
@@ -18,6 +18,8 @@ namespace Datadog.Unity.Flags
internal class PrecomputeAssignmentsFetcher
{
public const int FetchTimeoutSeconds = 30;
+ private static readonly string CapabilitiesHeader = string.Join(",",
+ new SortedSet(StringComparer.Ordinal) { FlagKeyObfuscation.Capability });
private readonly string _endpointUrl;
private readonly string _clientToken;
@@ -43,24 +45,11 @@ public PrecomputeAssignmentsFetcher(
/// Fetches precomputed assignments for the given evaluation context.
/// Uses a callback since UnityWebRequest can be used from coroutines.
///
- public void Fetch(FlagsEvaluationContext context, Action> onComplete)
+ public virtual void Fetch(FlagsEvaluationContext context, Action onComplete)
{
try
{
- var requestBody = BuildRequestBody(context);
- var bodyBytes = Encoding.UTF8.GetBytes(requestBody);
-
- var request = new UnityWebRequest(_endpointUrl, "POST");
- request.uploadHandler = new UploadHandlerRaw(bodyBytes);
- request.downloadHandler = new DownloadHandlerBuffer();
- request.timeout = FetchTimeoutSeconds;
- request.SetRequestHeader("Content-Type", "application/vnd.api+json");
- request.SetRequestHeader("dd-client-token", _clientToken);
-
- if (!string.IsNullOrEmpty(_applicationId))
- {
- request.SetRequestHeader("dd-application-id", _applicationId);
- }
+ var request = BuildRequest(context);
var operation = request.SendWebRequest();
operation.completed += _ =>
@@ -100,7 +89,23 @@ public void Fetch(FlagsEvaluationContext context, Action ParseResponse(string json)
+ internal static FlagAssignments ParseResponse(string json)
{
var flags = new Dictionary();
if (string.IsNullOrEmpty(json))
{
- return flags;
+ return null;
}
- AssignmentsResponseDto response;
+ JObject response;
try
{
- response = JsonConvert.DeserializeObject(json);
+ response = JObject.Parse(json);
}
catch
{
- return flags;
+ return null;
}
- var flagsDict = response?.Data?.Attributes?.Flags;
+ var attributes = response["data"]?["attributes"] as JObject;
+ var encoding = FlagKeyObfuscation.Read(attributes?["obfuscated"], attributes?["obfuscation"]);
+ var flagsDict = attributes?["flags"]?.ToObject>();
if (flagsDict == null)
{
- return flags;
+ return null;
}
foreach (var kvp in flagsDict)
{
+ if (encoding != null && !FlagKeyObfuscation.IsLowercaseHex(kvp.Key, 64))
+ throw new JsonSerializationException("Invalid obfuscated flag-map key.");
var dto = kvp.Value;
flags[kvp.Key] = new FlagAssignment(
variationType: dto.VariationType,
@@ -204,7 +213,7 @@ internal static Dictionary ParseResponse(string json)
reason: dto.Reason);
}
- return flags;
+ return new FlagAssignments(flags, encoding);
}
private class AssignmentsRequestDto
@@ -249,24 +258,6 @@ private class AssignmentsSubjectDto
public IReadOnlyDictionary TargetingAttributes { get; set; }
}
- private class AssignmentsResponseDto
- {
- [JsonProperty("data")]
- public AssignmentsResponseDataDto Data { get; set; }
- }
-
- private class AssignmentsResponseDataDto
- {
- [JsonProperty("attributes")]
- public AssignmentsResponseAttributesDto Attributes { get; set; }
- }
-
- private class AssignmentsResponseAttributesDto
- {
- [JsonProperty("flags")]
- public Dictionary Flags { get; set; }
- }
-
private class FlagAssignmentDto
{
[JsonProperty("variationType")]
diff --git a/packages/Datadog.Unity/Tests/Flags/FlagKeyObfuscationTests.cs b/packages/Datadog.Unity/Tests/Flags/FlagKeyObfuscationTests.cs
new file mode 100644
index 00000000..ce5be6e0
--- /dev/null
+++ b/packages/Datadog.Unity/Tests/Flags/FlagKeyObfuscationTests.cs
@@ -0,0 +1,295 @@
+// Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
+// This product includes software developed at Datadog (https://www.datadoghq.com/).
+// Copyright 2026-Present Datadog, Inc.
+
+using System.Collections.Generic;
+using System;
+using System.Text;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using NUnit.Framework;
+
+namespace Datadog.Unity.Flags.Tests
+{
+ public class FlagKeyObfuscationTests
+ {
+ private const string Salt = "000102030405060708090a0b0c0d0e0f";
+
+ private static JObject Descriptor(string salt = Salt) => new()
+ {
+ ["scheme"] = "flag-key-sha256-v1",
+ ["salt"] = salt,
+ };
+
+ private static FlagKeyObfuscation Encoding(string salt = Salt) =>
+ FlagKeyObfuscation.Read(new JValue(true), Descriptor(salt));
+
+ private static JObject Assignment(string type = "boolean", JToken value = null) => new()
+ {
+ ["variationType"] = type,
+ ["variationValue"] = value ?? new JValue(true),
+ ["variationKey"] = "treatment",
+ ["allocationKey"] = "allocation-123",
+ ["reason"] = "TARGETING_MATCH",
+ ["doLog"] = true,
+ };
+
+ private static JObject Response(JObject attributes) => new()
+ {
+ ["data"] = new JObject { ["attributes"] = attributes },
+ };
+
+ private static FlagAssignments Decode(string key, JObject assignment, string salt = Salt)
+ {
+ var attributes = new JObject
+ {
+ ["flags"] = new JObject { [Encoding(salt).Encode(key)] = assignment },
+ ["obfuscated"] = true,
+ ["obfuscation"] = Descriptor(salt),
+ };
+ return PrecomputeAssignmentsFetcher.ParseResponse(Response(attributes).ToString());
+ }
+
+ // Fixed vectors also run against the browser SDK and Rust edge encoder.
+ [TestCase("new-route-planner", "a60479237ef2f69175bbe0bd581966d1583766941815dc1d414c883767795190")]
+ [TestCase("Flag", "adca75d2141c51b0c0f084c1058edfb8e6e763f91aaf54ca06326d9847bd9586")]
+ [TestCase("flag", "9817872c144b018abd77e3915bd77e2c27f4f534dccff8ffaca27361e3a5e1ee")]
+ [TestCase(" flag ", "b1a5f851cc82a3fdf03a461d72a2241584dcf455df1d384e02a937901b3bc80b")]
+ [TestCase("café", "3bfa8c3c17c1b61035b98ecf14007cdf5cba5ce61a48e8cfb65f8106541892d3")]
+ [TestCase("cafe\u0301", "1e8b7ec5e8028a1ec96b38dd37f6ccea041c0a90358904af40ac5810c23c8765")]
+ [TestCase("🚲/旗", "94b611e0d3b26b52f6ad66013d1c72f8a92ea109390049759e75d3c8d3aa4dab")]
+ [TestCase("a\0b", "bac134d201be5e7f28fc7019248f0809c2a013b137e866446ee71add2bc344c7")]
+ [TestCase("", "072d985b427f536ad0a11b2d4c5e0f7e6f0ff6d23e779e082f75599ce3fe3eba")]
+ public void MatchesSharedHashVectors(string key, string expected)
+ {
+ Assert.AreEqual(expected, Encoding().Encode(key));
+ }
+
+ [Test]
+ public void BoundsLookupHashesAndKeepsDescriptorsSeparate()
+ {
+ var encoding = Encoding();
+ var other = Encoding(new string('f', 32));
+ var first = encoding.Encode("flag");
+ Assert.AreSame(first, encoding.Encode("flag"));
+ Assert.AreNotEqual(first, other.Encode("flag"));
+ Assert.AreSame(first, encoding.Encode("flag"));
+ Assert.AreNotEqual(encoding.Encode("café"), encoding.Encode("cafe\u0301"));
+ for (var index = 0; index < 1024; index++) encoding.Encode($"flag-{index}");
+ var afterEviction = encoding.Encode("flag");
+ Assert.AreEqual(first, afterEviction);
+ Assert.AreNotSame(first, afterEviction);
+ }
+
+ [Test]
+ public void RejectsInvalidUnicodeWithoutAliasingReplacementCharacter()
+ {
+ Assert.Throws(() => Encoding().Encode("\ud800"));
+ var repository = new FlagsRepository();
+ repository.SetFlagsAndContext(new FlagsEvaluationContext("athlete"), Decode("\ufffd", Assignment()));
+ Assert.IsNull(repository.GetFlagAssignment("\ud800"));
+ Assert.IsNotNull(repository.GetFlagAssignment("\ufffd"));
+ }
+
+ [TestCase("{}")]
+ [TestCase("{\"obfuscated\":false}")]
+ public void AcceptsLegacyMetadata(string metadata)
+ {
+ var attributes = JObject.Parse(metadata);
+ attributes["flags"] = new JObject { ["flag"] = Assignment() };
+ var decoded = PrecomputeAssignmentsFetcher.ParseResponse(Response(attributes).ToString());
+ Assert.IsNull(decoded.Obfuscation);
+ Assert.IsTrue(decoded.Flags.ContainsKey("flag"));
+ }
+
+ [TestCase("{\"obfuscated\":true}")]
+ [TestCase("{\"obfuscated\":null}")]
+ [TestCase("{\"obfuscated\":\"true\"}")]
+ [TestCase("{\"obfuscation\":null}")]
+ [TestCase("{\"obfuscation\":{}}")]
+ [TestCase("{\"obfuscated\":true,\"obfuscation\":null}")]
+ [TestCase("{\"obfuscated\":true,\"obfuscation\":[]}")]
+ [TestCase("{\"obfuscated\":true,\"obfuscation\":{\"scheme\":\"future\",\"salt\":\"000102030405060708090a0b0c0d0e0f\"}}")]
+ [TestCase("{\"obfuscated\":false,\"obfuscation\":{\"scheme\":\"flag-key-sha256-v1\",\"salt\":\"000102030405060708090a0b0c0d0e0f\"}}")]
+ public void RejectsMalformedMetadata(string metadata)
+ {
+ var attributes = JObject.Parse(metadata);
+ attributes["flags"] = new JObject();
+ Assert.Throws(() => PrecomputeAssignmentsFetcher.ParseResponse(Response(attributes).ToString()));
+ }
+
+ [TestCase("")]
+ [TestCase("000102030405060708090a0b0c0d0e")]
+ [TestCase("000102030405060708090a0b0c0d0e0f00")]
+ [TestCase("000102030405060708090A0B0C0D0E0F")]
+ [TestCase("000102030405060708090a0b0c0d0e0g")]
+ [TestCase("000102030405060708090a0b0c0d0e0f\n")]
+ [TestCase("000102030405060708090a0b0c0d0e0\n")]
+ public void RejectsInvalidSalt(string salt)
+ {
+ Assert.Throws(() => Encoding(salt));
+ }
+
+ [Test]
+ public void RejectsInvalidEncodedMapKey()
+ {
+ foreach (var key in new[] { "plaintext", new string('a', 63), new string('a', 65), new string('A', 64), new string('a', 64) + "\n", new string('a', 63) + "\n" })
+ {
+ var attributes = new JObject
+ {
+ ["obfuscated"] = true, ["obfuscation"] = Descriptor(),
+ ["flags"] = new JObject { [key] = Assignment() },
+ };
+ Assert.Throws(() => PrecomputeAssignmentsFetcher.ParseResponse(Response(attributes).ToString()));
+ }
+ }
+
+ [TestCase("boolean", "true")]
+ [TestCase("string", "\"visible-value\"")]
+ [TestCase("integer", "42")]
+ [TestCase("number", "12.5")]
+ [TestCase("object", "{\"visible\":[42,true]}")]
+ public void PreservesAssignmentsAndChangesOnlyLookupKeys(string type, string value)
+ {
+ var assignment = Assignment(type, JToken.Parse(value));
+ var plain = PrecomputeAssignmentsFetcher.ParseResponse(Response(new JObject
+ {
+ ["flags"] = new JObject { ["flag"] = assignment },
+ }).ToString());
+ var repository = new FlagsRepository();
+ repository.SetFlagsAndContext(new FlagsEvaluationContext("athlete"), Decode("flag", assignment));
+ var actual = repository.GetFlagAssignment("flag");
+ var expected = plain.Flags["flag"];
+ Assert.AreEqual(expected.VariationType, actual.VariationType);
+ Assert.IsTrue(JToken.DeepEquals(expected.VariationValue, actual.VariationValue));
+ Assert.AreEqual(expected.VariationKey, actual.VariationKey);
+ Assert.AreEqual(expected.AllocationKey, actual.AllocationKey);
+ Assert.AreEqual(expected.Reason, actual.Reason);
+ Assert.AreEqual(expected.DoLog, actual.DoLog);
+ Assert.IsNull(repository.GetFlagAssignment("missing"));
+ Assert.IsNull(repository.GetFlagAssignment(Encoding().Encode("flag")), "No plaintext lookup fallback.");
+ }
+
+ [Test]
+ public void AcceptsNewKeysAndFieldsAndPreservesUnknownVariantBehavior()
+ {
+ var encoding = Encoding();
+ var attributes = new JObject
+ {
+ ["obfuscated"] = true, ["obfuscation"] = Descriptor(), ["future-field"] = true,
+ ["flags"] = new JObject
+ {
+ [encoding.Encode("flag")] = Assignment(),
+ [encoding.Encode("new-flag")] = Assignment(),
+ [encoding.Encode("future")] = Assignment("future-type"),
+ },
+ };
+ var repository = new FlagsRepository();
+ repository.SetFlagsAndContext(new FlagsEvaluationContext("athlete"),
+ PrecomputeAssignmentsFetcher.ParseResponse(Response(attributes).ToString()));
+ using var client = new FlagsClient(repository, null, null, null, null,
+ false, false, null, FlagsClientState.Ready);
+ Assert.IsTrue(client.GetBooleanValue("flag", false));
+ Assert.IsTrue(client.GetBooleanValue("new-flag", false));
+ // Unity converts the value without requiring a known variationType. Encoding must not change that.
+ var plain = PrecomputeAssignmentsFetcher.ParseResponse(Response(new JObject
+ {
+ ["flags"] = new JObject { ["future"] = Assignment("future-type") },
+ }).ToString());
+ Assert.IsTrue(plain.Flags["future"].TryGetValue(out var expected));
+ Assert.AreEqual(expected, client.GetBooleanValue("future", false));
+ }
+
+ [Test]
+ public void RetainsOriginalDetailsAndExposureIdentityAcrossSaltChanges()
+ {
+ var repository = new FlagsRepository();
+ var context = new FlagsEvaluationContext("athlete");
+ var exposures = new List();
+ using var client = new FlagsClient(repository, new ExposureTracker(), null, null, null,
+ true, false, exposures.Add, FlagsClientState.Ready);
+
+ foreach (var salt in new[] { Salt, new string('f', 32), Salt })
+ {
+ repository.SetFlagsAndContext(context, Decode("flag", Assignment(), salt));
+ var details = client.GetBooleanDetails("flag", false);
+ Assert.IsTrue(details.Value);
+ Assert.AreEqual("flag", details.Key);
+ Assert.AreEqual("treatment", details.Variant);
+ Assert.AreEqual("allocation-123", details.AllocationKey);
+ }
+ Assert.AreEqual(1, exposures.Count);
+ StringAssert.Contains("\"key\":\"flag\"", JsonConvert.SerializeObject(exposures[0]));
+
+ repository.SetFlagsAndContext(new FlagsEvaluationContext("other"), Decode("other-flag", Assignment()));
+ Assert.IsNull(repository.GetFlagAssignment("flag"));
+ Assert.IsNotNull(repository.GetFlagAssignment("other-flag"));
+ }
+
+ [Test]
+ public void PreservesPublicApiDefaultsAndEvaluationTelemetry()
+ {
+ var repository = new FlagsRepository();
+ repository.SetFlagsAndContext(new FlagsEvaluationContext("athlete"), Decode("flag", Assignment("string", new JValue("not-a-boolean"))));
+ var events = new List();
+ var aggregator = new EvaluationAggregator(events.AddRange);
+ using var client = new FlagsClient(repository, null, aggregator, null, null,
+ false, true, null, FlagsClientState.Ready);
+ Assert.AreEqual(FlagEvaluationError.TypeMismatch, client.GetBooleanDetails("flag", false).Error);
+ Assert.AreEqual(FlagEvaluationError.FlagNotFound, client.GetBooleanDetails("missing", false).Error);
+ client.Flush();
+ Assert.AreEqual(2, events.Count);
+ Assert.IsTrue(events.Exists(value => value.Flag.Key == "flag"));
+ Assert.IsTrue(events.Exists(value => value.Flag.Key == "missing"));
+ }
+
+ [Test]
+ public void AdvertisesAssignmentEncodingCapability()
+ {
+ var fetcher = new PrecomputeAssignmentsFetcher("https://example.invalid", "token", null, "prod", null);
+ using var httpRequest = fetcher.BuildRequest(new FlagsEvaluationContext("athlete"));
+ Assert.AreEqual("assignment-encoding-flag-key-256-v1", httpRequest.GetRequestHeader("X-DD-FEATURE-FLAGS-CAPABILITIES"));
+ var request = JObject.Parse(fetcher.BuildRequestBody(new FlagsEvaluationContext("athlete")));
+ var attributes = request["data"]["attributes"];
+ Assert.IsNull(attributes["supported_capabilities"]);
+ Assert.AreEqual("athlete", attributes["subject"]["targeting_key"].Value());
+ }
+
+ [TestCase(false, false)]
+ [TestCase(true, false)]
+ [TestCase(false, true)]
+ public void InvalidEncodingRetainsOnlySameContextFallback(bool changeSubject, bool changeAttributes)
+ {
+ var repository = new FlagsRepository();
+ var context = new FlagsEvaluationContext("athlete", new Dictionary { ["country"] = "US" });
+ repository.SetFlagsAndContext(context, Decode("flag", Assignment()));
+ var invalid = Response(new JObject { ["obfuscated"] = true, ["flags"] = new JObject() }).ToString();
+ using var client = new FlagsClient(repository, null, null, new ResponseFetcher(invalid), null,
+ false, false, null, FlagsClientState.Ready);
+ var next = new FlagsEvaluationContext(changeSubject ? "other" : "athlete",
+ new Dictionary { ["country"] = changeAttributes ? "FR" : "US" });
+ var succeeded = true;
+ client.SetEvaluationContext(next, value => succeeded = value);
+ Assert.IsFalse(succeeded);
+ Assert.AreEqual(!changeSubject && !changeAttributes, client.GetBooleanValue("flag", false));
+ Assert.AreEqual(changeSubject || changeAttributes ? FlagsClientState.Error : FlagsClientState.Stale, client.State);
+ }
+
+ private sealed class ResponseFetcher : PrecomputeAssignmentsFetcher
+ {
+ private readonly string _response;
+ internal ResponseFetcher(string response) : base("https://example.invalid", "token", null, "prod", null)
+ {
+ _response = response;
+ }
+
+ public override void Fetch(FlagsEvaluationContext context, Action onComplete)
+ {
+ FlagAssignments assignments;
+ try { assignments = ParseResponse(_response); }
+ catch (JsonException) { assignments = null; }
+ onComplete(assignments);
+ }
+ }
+ }
+}
diff --git a/packages/Datadog.Unity/Tests/Flags/FlagKeyObfuscationTests.cs.meta b/packages/Datadog.Unity/Tests/Flags/FlagKeyObfuscationTests.cs.meta
new file mode 100644
index 00000000..a68ad861
--- /dev/null
+++ b/packages/Datadog.Unity/Tests/Flags/FlagKeyObfuscationTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 73a2e239b292488496bac941292c63f2
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/packages/Datadog.Unity/Tests/Flags/PrecomputeParserTests.cs b/packages/Datadog.Unity/Tests/Flags/PrecomputeParserTests.cs
index 35935eee..8078679f 100644
--- a/packages/Datadog.Unity/Tests/Flags/PrecomputeParserTests.cs
+++ b/packages/Datadog.Unity/Tests/Flags/PrecomputeParserTests.cs
@@ -29,7 +29,7 @@ public void ParsesValidBooleanFlag()
}
}";
- var flags = PrecomputeAssignmentsFetcher.ParseResponse(json);
+ var flags = PrecomputeAssignmentsFetcher.ParseResponse(json).Flags;
Assert.AreEqual(1, flags.Count);
Assert.IsTrue(flags.ContainsKey("enable-feature"));
@@ -87,7 +87,7 @@ public void ParsesMultipleFlags()
}
}";
- var flags = PrecomputeAssignmentsFetcher.ParseResponse(json);
+ var flags = PrecomputeAssignmentsFetcher.ParseResponse(json).Flags;
Assert.AreEqual(4, flags.Count);
@@ -105,24 +105,22 @@ public void ParsesMultipleFlags()
public void ParsesEmptyFlagsResponse()
{
var json = @"{""data"":{""attributes"":{""flags"":{}}}}";
- var flags = PrecomputeAssignmentsFetcher.ParseResponse(json);
+ var flags = PrecomputeAssignmentsFetcher.ParseResponse(json).Flags;
Assert.AreEqual(0, flags.Count);
}
[Test]
- public void ParsesInvalidJsonReturnsEmptyDict()
+ public void ParsesInvalidJsonReturnsFailure()
{
var flags = PrecomputeAssignmentsFetcher.ParseResponse("not json");
- Assert.IsNotNull(flags);
- Assert.AreEqual(0, flags.Count);
+ Assert.IsNull(flags);
}
[Test]
- public void ParsesNullJsonReturnsEmptyDict()
+ public void ParsesNullJsonReturnsFailure()
{
var flags = PrecomputeAssignmentsFetcher.ParseResponse(null);
- Assert.IsNotNull(flags);
- Assert.AreEqual(0, flags.Count);
+ Assert.IsNull(flags);
}
}
}