Skip to content
Open
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
22 changes: 22 additions & 0 deletions packages/Datadog.Unity/Runtime/Flags/FlagAssignments.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>Keeps the assignment map and its lookup encoding together.</summary>
internal sealed class FlagAssignments
{
internal readonly IReadOnlyDictionary<string, FlagAssignment> Flags;
internal readonly FlagKeyObfuscation Obfuscation;

internal FlagAssignments(Dictionary<string, FlagAssignment> flags, FlagKeyObfuscation obfuscation = null)
{
Flags = new ReadOnlyDictionary<string, FlagAssignment>(new Dictionary<string, FlagAssignment>(flags));
Obfuscation = obfuscation;
}
}
}
11 changes: 11 additions & 0 deletions packages/Datadog.Unity/Runtime/Flags/FlagAssignments.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

87 changes: 87 additions & 0 deletions packages/Datadog.Unity/Runtime/Flags/FlagKeyObfuscation.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>Public response encoding metadata. This is obfuscation, not encryption.</summary>
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<string, string> _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<bool>())) && descriptor == null)
return null;

if (marker?.Type != JTokenType.Boolean || !marker.Value<bool>() || !(descriptor is JObject metadata))
throw new JsonSerializationException("Invalid flag-key obfuscation metadata.");

if (metadata["scheme"]?.Type != JTokenType.String || metadata["scheme"].Value<string>() != Scheme)
throw new JsonSerializationException("Unsupported flag-key obfuscation scheme.");

var salt = metadata["salt"];
if (salt?.Type != JTokenType.String || !IsLowercaseHex(salt.Value<string>(), 32))
throw new JsonSerializationException("Flag-key salt must contain 32 lowercase hexadecimal characters.");

return new FlagKeyObfuscation(salt.Value<string>());
}

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;
}
}
}
11 changes: 11 additions & 0 deletions packages/Datadog.Unity/Runtime/Flags/FlagKeyObfuscation.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/Datadog.Unity/Runtime/Flags/FlagsClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ public void SetEvaluationContext(FlagsEvaluationContext context, Action<bool> 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 =>
Expand Down
49 changes: 43 additions & 6 deletions packages/Datadog.Unity/Runtime/Flags/FlagsRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// Copyright 2025-Present Datadog, Inc.

using System.Collections.Generic;
using System.Text;

namespace Datadog.Unity.Flags
{
Expand All @@ -12,7 +13,7 @@ namespace Datadog.Unity.Flags
internal class FlagsRepository
{
private readonly object _lock = new();
private Dictionary<string, FlagAssignment> _flags = new();
private FlagAssignments _assignments = new(new Dictionary<string, FlagAssignment>());
private FlagsEvaluationContext _context;

/// <summary>
Expand All @@ -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;
}
}
Expand All @@ -45,33 +56,59 @@ public FlagAssignment GetFlagAssignment(string key)
/// Sets the flags and context atomically.
/// </summary>
public void SetFlagsAndContext(FlagsEvaluationContext context, Dictionary<string, FlagAssignment> flags)
{
SetFlagsAndContext(context, new FlagAssignments(flags ?? new Dictionary<string, FlagAssignment>()));
}

public void SetFlagsAndContext(FlagsEvaluationContext context, FlagAssignments assignments)
{
lock (_lock)
{
_context = context;
_flags = flags ?? new Dictionary<string, FlagAssignment>();
_assignments = assignments;
}
}

/// <summary>Keep fallback assignments only when the complete requested context matches.</summary>
public void PrepareContext(FlagsEvaluationContext context)
{
lock (_lock)
{
if (ContextsMatch(_context, context)) return;
_context = context;
_assignments = new FlagAssignments(new Dictionary<string, FlagAssignment>());
}
}

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;
}

/// <summary>
/// Returns true if any flags are cached.
/// </summary>
public bool HasFlags()
{
lock (_lock)
{
return _flags.Count > 0;
return _assignments.Flags.Count > 0;
}
}

/// <summary>
/// Returns a snapshot of all cached flags.
/// </summary>
public Dictionary<string, FlagAssignment> GetFlagsSnapshot()
public FlagAssignments GetFlagsSnapshot()
{
lock (_lock)
{
return new Dictionary<string, FlagAssignment>(_flags);
return _assignments;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(StringComparer.Ordinal) { FlagKeyObfuscation.Capability });

private readonly string _endpointUrl;
private readonly string _clientToken;
Expand All @@ -43,24 +45,11 @@ public PrecomputeAssignmentsFetcher(
/// Fetches precomputed assignments for the given evaluation context.
/// Uses a callback since UnityWebRequest can be used from coroutines.
/// </summary>
public void Fetch(FlagsEvaluationContext context, Action<Dictionary<string, FlagAssignment>> onComplete)
public virtual void Fetch(FlagsEvaluationContext context, Action<FlagAssignments> 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 += _ =>
Expand Down Expand Up @@ -100,7 +89,23 @@ public void Fetch(FlagsEvaluationContext context, Action<Dictionary<string, Flag
}
}

private string BuildRequestBody(FlagsEvaluationContext context)
internal UnityWebRequest BuildRequest(FlagsEvaluationContext context)
{
var request = new UnityWebRequest(_endpointUrl, "POST");
request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(BuildRequestBody(context)));
request.downloadHandler = new DownloadHandlerBuffer();
request.timeout = FetchTimeoutSeconds;
request.SetRequestHeader("Content-Type", "application/vnd.api+json");
request.SetRequestHeader("dd-client-token", _clientToken);
request.SetRequestHeader("X-DD-FEATURE-FLAGS-CAPABILITIES", CapabilitiesHeader);
if (!string.IsNullOrEmpty(_applicationId))
{
request.SetRequestHeader("dd-application-id", _applicationId);
}
return request;
}

internal string BuildRequestBody(FlagsEvaluationContext context)
{
var dto = new AssignmentsRequestDto
{
Expand Down Expand Up @@ -167,33 +172,37 @@ internal static string ExtractServerError(string body, long httpCode)
return $"unreadable error response (HTTP {httpCode})";
}

internal static Dictionary<string, FlagAssignment> ParseResponse(string json)
internal static FlagAssignments ParseResponse(string json)
{
var flags = new Dictionary<string, FlagAssignment>();

if (string.IsNullOrEmpty(json))
{
return flags;
return null;
}

AssignmentsResponseDto response;
JObject response;
try
{
response = JsonConvert.DeserializeObject<AssignmentsResponseDto>(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<Dictionary<string, FlagAssignmentDto>>();
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,
Expand All @@ -204,7 +213,7 @@ internal static Dictionary<string, FlagAssignment> ParseResponse(string json)
reason: dto.Reason);
}

return flags;
return new FlagAssignments(flags, encoding);
}

private class AssignmentsRequestDto
Expand Down Expand Up @@ -249,24 +258,6 @@ private class AssignmentsSubjectDto
public IReadOnlyDictionary<string, string> 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<string, FlagAssignmentDto> Flags { get; set; }
}

private class FlagAssignmentDto
{
[JsonProperty("variationType")]
Expand Down
Loading