Skip to content
Draft
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
2 changes: 1 addition & 1 deletion manifests/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -821,7 +821,7 @@ manifest:
tests/parametric/test_dynamic_configuration.py::TestDynamicConfigV2: v2.44.0
tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation: '>=3.36.0' # Modified by easy win activation script
tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation::test_ffe_flag_evaluation: missing_feature # Created by easy win activation script
tests/parametric/test_ffe/test_span_enrichment.py: missing_feature
tests/parametric/test_ffe/test_span_enrichment.py: '>=3.36.0'
tests/parametric/test_headers_b3.py::Test_Headers_B3::test_headers_b3_migrated_extract_valid: missing_feature (Need to remove b3=b3multi alias)
tests/parametric/test_headers_b3.py::Test_Headers_B3::test_headers_b3_migrated_inject_valid: missing_feature (Need to remove b3=b3multi alias)
tests/parametric/test_headers_b3.py::Test_Headers_B3::test_headers_b3_migrated_propagate_invalid: missing_feature (Need to remove b3=b3multi alias)
Expand Down
11 changes: 11 additions & 0 deletions utils/build/docker/dotnet/parametric/ApmTestApi.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />

<PackageReference Include="Datadog.Trace" Version="*" />

<!-- FFE span-enrichment L2 lane (Phase 1): the OpenFeature .NET SDK + the in-repo Datadog
provider drive /ffe/start and /ffe/evaluate. The provider lives in dd-trace-dotnet
(tracer/src/Datadog.FeatureFlags.OpenFeature) but that worktree is NOT in this Docker
build context, so it is consumed as its published NuGet package rather than a
ProjectReference. The OpenFeature version is pinned to 2.3.0 to match exactly what
Datadog.FeatureFlags.OpenFeature 2.3.0 targets (Datadog.FeatureFlags.OpenFeature.csproj
PackageReference OpenFeature 2.3.0); a version skew here surfaces as a build/binding
failure that looks like an SDK bug. -->
<PackageReference Include="OpenFeature" Version="2.3.0" />
<PackageReference Include="Datadog.FeatureFlags.OpenFeature" Version="2.3.0" />
</ItemGroup>

</Project>
Binary file not shown.
6 changes: 5 additions & 1 deletion utils/build/docker/dotnet/parametric/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ WORKDIR /app
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1

# dotnet restore
COPY utils/build/docker/dotnet/parametric/ApmTestApi.csproj utils/build/docker/dotnet/parametric/nuget.config ./
# The enrichment-aware Datadog.FeatureFlags.OpenFeature 2.3.0 nupkg is shipped in the build
# context. The nuget.org-published 2.3.0 predates span enrichment and lacks SpanEnrichmentHook /
# the FeatureFlagsSdk.AccumulateSpanEnrichment stub, so restoring it attaches NO ffe_* tags.
# Copy it before restore so the local nuget source (ordered first in nuget.config) wins.
COPY utils/build/docker/dotnet/parametric/ApmTestApi.csproj utils/build/docker/dotnet/parametric/nuget.config utils/build/docker/dotnet/parametric/Datadog.FeatureFlags.OpenFeature.2.3.0.nupkg ./
RUN dotnet restore "./ApmTestApi.csproj"

# dotnet publish
Expand Down
208 changes: 208 additions & 0 deletions utils/build/docker/dotnet/parametric/Endpoints/ApmTestApi.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using Datadog.Trace;
using Datadog.FeatureFlags.OpenFeature;
using OpenFeature;
using OpenFeature.Model;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text.Json;
Expand Down Expand Up @@ -30,6 +33,12 @@ public static void MapApmTraceEndpoints(WebApplication app, ILogger logger)
app.MapPost("/trace/span/manual_drop", SpanManualDrop);
app.MapPost("/trace/span/finish", FinishSpan);
app.MapPost("/trace/span/flush", FlushSpans);

// FFE APM span-enrichment L2 lane (Phase 1). The other 4 parametric apps already host
// /ffe/*; .NET is the only one that needs the surface net-new. See _test_client_parametric.py
// (ffe_start / ffe_evaluate) for the frozen HTTP contract.
app.MapPost("/ffe/start", FfeStart);
app.MapPost("/ffe/evaluate", FfeEvaluate);
}

private const BindingFlags CommonBindingFlags = BindingFlags.Instance | BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public;
Expand Down Expand Up @@ -69,6 +78,9 @@ public static void MapApmTraceEndpoints(WebApplication app, ILogger logger)
private static readonly Dictionary<ulong, ISpanContext> SpanContexts = new();
private static ILogger? _logger;

// FFE OpenFeature client, created by /ffe/start.
private static FeatureClient? _ffeClient;

// global config reflection
private static MethodInfo? _getConfigurationString;
private static object? _telemetryInstance;
Expand Down Expand Up @@ -383,6 +395,202 @@ private static ISpan FindSpan(JsonElement json, string key = "span_id")
return span;
}

// Non-throwing span lookup for /ffe/evaluate. The test client sends span_id as a STRING (see
// _test_client_parametric.py:814-815). An unknown/missing/unparsable id returns null so the
// caller can skip activation and evaluate normally rather than 500 (T-01-DOS; matches the
// skip-don't-throw rule the other 4 SDKs use).
private static ISpan? TryFindSpan(JsonElement json, string key = "span_id")
{
if (!json.TryGetProperty(key, out var prop) || prop.ValueKind == JsonValueKind.Null)
{
return null;
}

// span_id arrives as a JSON string; tolerate a numeric form too.
ulong spanId;
switch (prop.ValueKind)
{
case JsonValueKind.String when ulong.TryParse(prop.GetString(), out var parsed):
spanId = parsed;
break;
case JsonValueKind.Number when prop.TryGetUInt64(out var num):
spanId = num;
break;
default:
_logger?.LogInformation("FFE evaluate: span_id is not a parseable id; skipping activation.");
return null;
}

if (!Spans.TryGetValue(spanId, out var span))
{
_logger?.LogInformation("FFE evaluate: span {spanId} not found; skipping activation.", spanId);
return null;
}

return span;
}

// POST /ffe/start — initialize the Datadog OpenFeature provider and client. The test only
// checks that the response is a 2xx (HTTPStatus(...).is_success); return { success = true }.
private static async Task<string> FfeStart()
{
await Api.Instance.SetProviderAsync(new DatadogProvider());
_ffeClient = Api.Instance.GetClient();

_logger?.LogInformation("FFE provider initialized.");
return Result(new { success = true });
}

// POST /ffe/evaluate — evaluate a flag through the OpenFeature client, re-activating the
// caller-supplied root span for the duration of the eval so the ffe_* tags (Phase 2) land on
// the test's span.
//
// Cross-request re-activation (the .NET-specific hard part / WQ-001 stop-guard): the span was
// created by a prior /trace/span/start request whose StartActive scope was disposed when that
// request returned, so the span is in Spans but is no longer the active scope. There is no
// PUBLIC Tracer.ActivateSpan(span) on the Datadog.Trace manual API (Tracer.ActivateSpan is
// internal); the public re-activation primitive is StartActive(name, settings) with
// settings.Parent = storedSpan.Context, which makes the stored span's trace the active trace
// for the eval. The transient "ffe.evaluate" span uses FinishOnClose=true so disposing the
// scope finishes ONLY that child (never the stored root, which is closed later by
// /trace/span/finish). Leaving it open would keep the trace's pending-span count > 0, so the
// tracer would never flush the trace and the test agent would receive zero traces.
private static async Task<string> FfeEvaluate(HttpRequest request)
{
if (_ffeClient is null)
{
_logger?.LogError("FFE evaluate called before /ffe/start; provider not initialized.");
throw new InvalidOperationException("FFE provider not initialized");
}

var requestJson = await ParseJsonAsync(request.Body);

var flag = requestJson.GetProperty("flag").GetString() ?? string.Empty;
var variationType = requestJson.GetProperty("variationType").GetString() ?? string.Empty;
var defaultValueElement = requestJson.GetProperty("defaultValue");

var contextBuilder = EvaluationContext.Builder();
if (requestJson.TryGetProperty("targetingKey", out var targetingKey) && targetingKey.ValueKind == JsonValueKind.String)
{
contextBuilder.SetTargetingKey(targetingKey.GetString()!);
}

if (requestJson.TryGetProperty("attributes", out var attributes) && attributes.ValueKind == JsonValueKind.Object)
{
foreach (var attribute in attributes.EnumerateObject())
{
contextBuilder.Set(attribute.Name, JsonElementToValue(attribute.Value));
}
}

var context = contextBuilder.Build();

// Re-activate the registered span (if any) around the eval. Unknown/missing id => null => skip.
var targetSpan = TryFindSpan(requestJson);

object? value;
var reason = "DEFAULT";

try
{
if (targetSpan is not null)
{
var reactivation = new SpanCreationSettings
{
Parent = targetSpan.Context,

// Finish this transient child on dispose so the trace's pending-span count returns
// to the (still-open) root only; an unfinished child would block the trace from
// ever flushing, so the test agent would receive zero traces.
FinishOnClose = true,
};

using var scope = Tracer.Instance.StartActive("ffe.evaluate", reactivation);
value = await EvaluateFlag(variationType, flag, defaultValueElement, context);
}
else
{
value = await EvaluateFlag(variationType, flag, defaultValueElement, context);
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "FFE evaluate failed for flag {flag}.", flag);
value = JsonElementToClr(defaultValueElement);
reason = "ERROR";
}

return Result(new { value, reason });
}

private static async Task<object?> EvaluateFlag(string variationType, string flag, JsonElement defaultValueElement, EvaluationContext context)
{
switch (variationType)
{
case "BOOLEAN":
var boolDefault = defaultValueElement.ValueKind is JsonValueKind.True or JsonValueKind.False && defaultValueElement.GetBoolean();
return await _ffeClient!.GetBooleanValueAsync(flag, boolDefault, context);
case "STRING":
return await _ffeClient!.GetStringValueAsync(flag, defaultValueElement.GetString() ?? string.Empty, context);
case "INTEGER":
return await _ffeClient!.GetIntegerValueAsync(flag, defaultValueElement.TryGetInt32(out var i) ? i : 0, context);
case "NUMERIC":
return await _ffeClient!.GetDoubleValueAsync(flag, defaultValueElement.TryGetDouble(out var d) ? d : 0d, context);
case "JSON":
var resolved = await _ffeClient!.GetObjectValueAsync(flag, JsonElementToValue(defaultValueElement), context);
return resolved?.AsObject;
default:
return JsonElementToClr(defaultValueElement);
}
}

// Map a JSON element into an OpenFeature Value (for evaluation context attributes + JSON defaults).
private static Value JsonElementToValue(JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.True:
case JsonValueKind.False:
return new Value(element.GetBoolean());
case JsonValueKind.Number:
return new Value(element.GetDouble());
case JsonValueKind.String:
return new Value(element.GetString() ?? string.Empty);
case JsonValueKind.Object:
var structureBuilder = Structure.Builder();
foreach (var property in element.EnumerateObject())
{
structureBuilder.Set(property.Name, JsonElementToValue(property.Value));
}

return new Value(structureBuilder.Build());
case JsonValueKind.Array:
var list = new List<Value>();
foreach (var item in element.EnumerateArray())
{
list.Add(JsonElementToValue(item));
}

return new Value(list);
default:
return new Value();
}
}

// Plain-CLR projection of a JSON default for the ERROR/echo path (kept JSON-serializable for Result()).
private static object? JsonElementToClr(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(),
JsonValueKind.String => element.GetString(),
JsonValueKind.Null or JsonValueKind.Undefined => null,
_ => element.GetRawText(),
};
}

private static ISpanContext? FindParentSpanContext(JsonElement json, string key = "parent_id")
{
var jsonProperty = json.GetProperty(key);
Expand Down
4 changes: 3 additions & 1 deletion utils/build/docker/dotnet/parametric/nuget.config
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
<packageSources>
<!--To inherit the global NuGet package sources remove the <clear/> line below -->
<clear />
<add key="nuget" value="https://api.nuget.org/v3/index.json" />
<!-- local first: the enrichment-aware Datadog.FeatureFlags.OpenFeature 2.3.0 nupkg ships in
the build context and MUST win over nuget.org's same-versioned (enrichment-less) package. -->
<add key="local" value="./" />
<add key="nuget" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
Loading