diff --git a/tracer/src/Datadog.Trace/FeatureFlags/Agentless/AgentlessConfigurationSource.cs b/tracer/src/Datadog.Trace/FeatureFlags/Agentless/AgentlessConfigurationSource.cs
new file mode 100644
index 000000000000..4f2658fda111
--- /dev/null
+++ b/tracer/src/Datadog.Trace/FeatureFlags/Agentless/AgentlessConfigurationSource.cs
@@ -0,0 +1,424 @@
+//
+// 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.IO.Compression;
+using System.Threading;
+using System.Threading.Tasks;
+using Datadog.Trace.Agent;
+using Datadog.Trace.Agent.Transports;
+using Datadog.Trace.FeatureFlags.Rcm.Model;
+using Datadog.Trace.Headers;
+using Datadog.Trace.Logging;
+using Datadog.Trace.Telemetry;
+using Datadog.Trace.Util;
+
+namespace Datadog.Trace.FeatureFlags.Agentless;
+
+///
+/// Polls the agentless endpoint for flag configuration. Polling is billable, so it is only
+/// started once application code has activated the provider.
+///
+internal sealed class AgentlessConfigurationSource : IDisposable
+{
+ private const int MaxAttempts = 3;
+ private const double RetryJitter = 0.2;
+
+ private static readonly TimeSpan FirstRetryMin = TimeSpan.FromSeconds(2);
+ private static readonly TimeSpan FirstRetryMax = TimeSpan.FromSeconds(10);
+ private static readonly TimeSpan SecondRetryMin = TimeSpan.FromSeconds(5);
+ private static readonly TimeSpan SecondRetryMax = TimeSpan.FromSeconds(30);
+
+ // A jittered retry delay never drops below this, so a short poll interval cannot turn
+ // retries into a burst against the endpoint.
+ private static readonly TimeSpan MinRetryDelay = TimeSpan.FromSeconds(1);
+
+ private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(typeof(AgentlessConfigurationSource));
+
+ private readonly IApiRequestFactory _requestFactory;
+ private readonly Uri _endpoint;
+ private readonly TimeSpan _pollInterval;
+ private readonly TimeSpan _requestTimeout;
+ private readonly Func _applyConfiguration;
+ private readonly Func _waitAsync;
+ private readonly CancellationTokenSource _shutdown = new();
+ private readonly Random _random = new();
+
+ // Only ever touched from the poll loop.
+ private readonly HashSet _loggedFailureCategories = new();
+ private bool _malformedPayloadLogged;
+ private bool _applyFailureLogged;
+ private string? _etag;
+
+ private int _started;
+
+ internal AgentlessConfigurationSource(
+ Uri endpoint,
+ IApiRequestFactory requestFactory,
+ TimeSpan pollInterval,
+ TimeSpan requestTimeout,
+ Func applyConfiguration,
+ Func? waitAsync = null)
+ {
+ _endpoint = endpoint;
+ _requestFactory = requestFactory;
+ _pollInterval = pollInterval;
+ _requestTimeout = requestTimeout;
+ _applyConfiguration = applyConfiguration;
+ _waitAsync = waitAsync ?? Task.Delay;
+ }
+
+ ///
+ /// Creates the source, or returns null when it cannot be operated: a base URL that is
+ /// not a URL, or the managed endpoint without an API key. Polling anyway would only produce
+ /// failures every interval.
+ ///
+ public static AgentlessConfigurationSource? Create(FeatureFlagsSettings settings, Func applyConfiguration)
+ {
+ if (!AgentlessEndpoint.TryCreate(settings.Site, settings.Env, settings.AgentlessBaseUrl, out var endpoint, out var error))
+ {
+ Log.Error("Feature Flags agentless source is unavailable: {Error}", error);
+ return null;
+ }
+
+ if (endpoint.IsManaged && StringUtil.IsNullOrEmpty(settings.ApiKey))
+ {
+ Log.Error("Feature Flags agentless source requires an API key. Set DD_API_KEY, or point DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL at an endpoint of your own.");
+ return null;
+ }
+
+ return new AgentlessConfigurationSource(
+ endpoint.Uri,
+ CreateRequestFactory(endpoint, settings),
+ settings.PollInterval,
+ settings.RequestTimeout,
+ applyConfiguration);
+ }
+
+ ///
+ /// Starts polling. Idempotent.
+ ///
+ public void Start()
+ {
+ if (Interlocked.CompareExchange(ref _started, 1, 0) != 0)
+ {
+ return;
+ }
+
+ // Deliberately not wrapped in Task.Run: this is called from provider initialization, which
+ // is waiting for the first configuration, so the first request should go out on the calling
+ // thread rather than queue behind whatever else is on the thread pool.
+ _ = RunAsync().ContinueWith(t => Log.Error(t.Exception, "Feature Flags agentless poll loop failed"), TaskContinuationOptions.OnlyOnFaulted);
+ }
+
+ ///
+ /// Runs a single poll, including its in-tick retries.
+ ///
+ internal async Task PollAsync()
+ {
+ var result = default(PollResult);
+
+ for (var attempt = 1; attempt <= MaxAttempts; attempt++)
+ {
+ result = await RequestAsync().ConfigureAwait(false);
+
+ if (_shutdown.IsCancellationRequested)
+ {
+ // A shutdown mid-poll leaves the response unusable for state transitions: keep
+ // last-known-good and the current ETag.
+ return;
+ }
+
+ if (!IsRetryable(result))
+ {
+ break;
+ }
+
+ if (attempt == MaxAttempts)
+ {
+ // Every attempt failed in a retryable way. Last-known-good stays in place.
+ WarnFailure(result, MaxAttempts);
+ return;
+ }
+
+ await WaitAsync(RetryDelay(attempt)).ConfigureAwait(false);
+
+ if (_shutdown.IsCancellationRequested)
+ {
+ return;
+ }
+ }
+
+ if (_shutdown.IsCancellationRequested)
+ {
+ // A shutdown during the final attempt leaves the response unusable for state
+ // transitions: keep last-known-good and the current ETag.
+ return;
+ }
+
+ await ApplyAsync(result).ConfigureAwait(false);
+ }
+
+ public void Dispose()
+ {
+ // The request in flight is bounded by the request timeout, and the loop is never joined,
+ // so a shutdown does not wait for it. A poll that completes after disposal is prevented
+ // from applying its result by the shutdown check in PollAsync.
+ try
+ {
+ _shutdown.Cancel();
+ }
+ catch (Exception ex)
+ {
+ Log.Debug(ex, "Error cancelling the Feature Flags agentless poll loop");
+ }
+ }
+
+ // The concrete type is returned rather than the interface because CA1859 asks for it on a
+ // private member, which is also why the signature varies by target framework.
+#if NETCOREAPP
+ private static HttpClientRequestFactory CreateRequestFactory(AgentlessEndpoint endpoint, FeatureFlagsSettings settings)
+#else
+ private static ApiWebRequestFactory CreateRequestFactory(AgentlessEndpoint endpoint, FeatureFlagsSettings settings)
+#endif
+ {
+ var headers = new List>
+ {
+ // The endpoint serves gzip, and neither transport decompresses for us.
+ new("Accept-Encoding", "gzip"),
+ new(TelemetryConstants.ClientLibraryLanguageHeader, TracerConstants.Language),
+ new(TelemetryConstants.ClientLibraryVersionHeader, TracerConstants.ThreePartVersion),
+
+ // Without this the poll is itself instrumented, producing a span per poll and letting
+ // auto-instrumentation recurse through the poller's own client.
+ new(HttpHeaderNames.TracingEnabled, "false"),
+ };
+
+ if (endpoint.IsManaged)
+ {
+ // A custom endpoint is left to report its own authentication failure rather than
+ // having the Datadog credential sent to it.
+ headers.Add(new(TelemetryConstants.ApiKeyHeader, settings.ApiKey!));
+ }
+
+#if NETCOREAPP
+ return new HttpClientRequestFactory(endpoint.Uri, headers.ToArray(), timeout: settings.RequestTimeout);
+#else
+ return new ApiWebRequestFactory(endpoint.Uri, headers.ToArray(), timeout: settings.RequestTimeout);
+#endif
+ }
+
+ private static bool IsRetryable(in PollResult result)
+ => result.StatusCode is not { } status || status is 408 or 429 or (>= 500 and <= 599);
+
+ private async Task RunAsync()
+ {
+ Log.Debug("AgentlessConfigurationSource::RunAsync -> Enter");
+
+ while (!_shutdown.IsCancellationRequested)
+ {
+ try
+ {
+ await PollAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Feature Flags agentless poll failed unexpectedly");
+ }
+
+ // Fixed delay after completion, so polls never overlap.
+ await WaitAsync(_pollInterval).ConfigureAwait(false);
+ }
+
+ Log.Debug("AgentlessConfigurationSource::RunAsync -> Exit");
+ }
+
+ private async Task WaitAsync(TimeSpan delay)
+ {
+ try
+ {
+ await _waitAsync(delay, _shutdown.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutting down
+ }
+ }
+
+ private TimeSpan RetryDelay(int attempt)
+ {
+ var seconds = attempt == 1
+ ? Clamp(_pollInterval.TotalSeconds / 6, FirstRetryMin, FirstRetryMax)
+ : Clamp(_pollInterval.TotalSeconds / 3, SecondRetryMin, SecondRetryMax);
+
+ double jitter;
+ lock (_random)
+ {
+ jitter = 1 - RetryJitter + (_random.NextDouble() * RetryJitter * 2);
+ }
+
+ return TimeSpan.FromSeconds(Math.Max(MinRetryDelay.TotalSeconds, seconds * jitter));
+
+ static double Clamp(double value, TimeSpan minimum, TimeSpan maximum)
+ => Math.Max(minimum.TotalSeconds, Math.Min(maximum.TotalSeconds, value));
+ }
+
+ private async Task RequestAsync()
+ {
+ try
+ {
+ var request = _requestFactory.Create(_endpoint);
+ if (_etag is { } etag)
+ {
+ request.AddHeader("If-None-Match", etag);
+ }
+
+#if NETCOREAPP
+ // HttpClient.Timeout applies to async calls, so no explicit race is needed.
+ using var response = await request.GetAsync().ConfigureAwait(false);
+#else
+ // HttpWebRequest.Timeout does not apply to async calls (GetResponseAsync), so we race
+ // the request against an explicit delay to bound the wait on net461/netstandard2.0.
+ var getTask = request.GetAsync();
+ var timeoutTask = Task.Delay(_requestTimeout);
+
+ if (await Task.WhenAny(getTask, timeoutTask).ConfigureAwait(false) == timeoutTask)
+ {
+ // The request is still in flight. Dispose the response when it eventually completes
+ // (success or fault) so the underlying connection is released.
+ _ = getTask.ContinueWith(
+ t => { try { using var r = t.Result; } catch { } },
+ TaskContinuationOptions.None);
+
+ return new PollResult(statusCode: null, etag: null, body: null, error: new TimeoutException($"Feature Flags agentless request timed out after {_requestTimeout.TotalSeconds}s"));
+ }
+
+ using var response = await getTask.ConfigureAwait(false);
+#endif
+
+ // Only a 200 carries configuration; other bodies are never decoded as one.
+ var body = response.StatusCode == 200 ? await ReadBodyAsync(response).ConfigureAwait(false) : null;
+ return new PollResult(response.StatusCode, response.GetHeader("ETag"), body, error: null);
+ }
+ catch (Exception ex)
+ {
+ return new PollResult(statusCode: null, etag: null, body: null, error: ex);
+ }
+ }
+
+ private async Task ReadBodyAsync(IApiResponse response)
+ {
+ var stream = await response.GetStreamAsync().ConfigureAwait(false);
+ GZipStream? decompressed = null;
+
+ try
+ {
+ if (response.GetContentEncodingType() == ContentEncodingType.GZip)
+ {
+ decompressed = new GZipStream(stream, CompressionMode.Decompress);
+ }
+
+ using var reader = new StreamReader(decompressed ?? stream, response.GetCharsetEncoding());
+ return await reader.ReadToEndAsync().ConfigureAwait(false);
+ }
+ finally
+ {
+ decompressed?.Dispose();
+ }
+ }
+
+ private Task ApplyAsync(PollResult result)
+ {
+ switch (result.StatusCode)
+ {
+ case 304:
+ // Nothing changed, and the ETag stays as it is.
+ return Task.CompletedTask;
+ case 401 or 403:
+ WarnFailure(result, attempts: 1);
+ return Task.CompletedTask;
+ case not 200:
+ WarnFailure(result, attempts: 1);
+ return Task.CompletedTask;
+ }
+
+ if (!UfcConfigurationParser.TryParse(result.Body, out var configuration, out var error))
+ {
+ if (!_malformedPayloadLogged)
+ {
+ _malformedPayloadLogged = true;
+ Log.Error("Feature Flags agentless endpoint returned an unusable payload: {Error}", error);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ if (!_applyConfiguration(configuration))
+ {
+ if (!_applyFailureLogged)
+ {
+ _applyFailureLogged = true;
+ Log.Warning("Feature Flags agentless configuration could not be applied");
+ }
+
+ return Task.CompletedTask;
+ }
+
+ // The ETag advances only once parsing and applying have both succeeded. Advancing on
+ // receipt would acknowledge a payload that was never applied, and every later poll would
+ // answer 304, pinning the process to stale configuration with no way back.
+ var newEtag = result.ETag?.Trim();
+ _etag = StringUtil.IsNullOrEmpty(newEtag) ? null : newEtag;
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Warns once per failure category. A dead endpoint would otherwise produce a warning every
+ /// poll interval, indefinitely.
+ ///
+ private void WarnFailure(in PollResult result, int attempts)
+ {
+ var category = result.StatusCode switch
+ {
+ 401 or 403 => "authentication",
+ not null => "http",
+ _ => "request",
+ };
+
+ if (!_loggedFailureCategories.Add(category))
+ {
+ return;
+ }
+
+ switch (result.StatusCode)
+ {
+ case 401 or 403:
+ Log.Error("Feature Flags agentless endpoint returned HTTP {StatusCode}; verify endpoint authentication", result.StatusCode!.Value);
+ break;
+ case not null:
+ Log.Error("Feature Flags agentless endpoint returned HTTP {StatusCode} after {Attempts} attempts", result.StatusCode.Value, attempts);
+ break;
+ default:
+ Log.Error(result.Error, "Feature Flags agentless request failed after {Attempts} attempts", attempts);
+ break;
+ }
+ }
+
+ internal readonly struct PollResult(int? statusCode, string? etag, string? body, Exception? error)
+ {
+ public int? StatusCode { get; } = statusCode;
+
+ public string? ETag { get; } = etag;
+
+ public string? Body { get; } = body;
+
+ public Exception? Error { get; } = error;
+ }
+}
diff --git a/tracer/src/Datadog.Trace/FeatureFlags/Agentless/UfcConfigurationParser.cs b/tracer/src/Datadog.Trace/FeatureFlags/Agentless/UfcConfigurationParser.cs
new file mode 100644
index 000000000000..a078f5f4374d
--- /dev/null
+++ b/tracer/src/Datadog.Trace/FeatureFlags/Agentless/UfcConfigurationParser.cs
@@ -0,0 +1,91 @@
+//
+// 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.Diagnostics.CodeAnalysis;
+using System.IO;
+using Datadog.Trace.FeatureFlags.Rcm.Model;
+using Datadog.Trace.Vendors.Newtonsoft.Json;
+using Datadog.Trace.Vendors.Newtonsoft.Json.Linq;
+
+namespace Datadog.Trace.FeatureFlags.Agentless;
+
+///
+/// Reads the JSON:API envelope returned by the agentless endpoint.
+///
+internal static class UfcConfigurationParser
+{
+ private const string ResourceType = "universal-flag-configuration";
+
+ ///
+ /// Validates a JSON:API Universal Flag Configuration response and returns data.attributes,
+ /// which is the document the evaluator consumes. A raw UFC document is rejected, including from
+ /// a custom endpoint, so that every source agrees on one wire format.
+ ///
+ /// The response body.
+ /// The parsed configuration.
+ /// Why the payload was rejected.
+ /// true when the payload matches the contract.
+ public static bool TryParse(string? body, [NotNullWhen(true)] out ServerConfiguration? configuration, out string? error)
+ {
+ configuration = null;
+ error = null;
+
+ JToken payload;
+ try
+ {
+ using var stringReader = new StringReader(body ?? string.Empty);
+
+ // Timestamps stay strings: the model carries createdAt verbatim, and letting Newtonsoft
+ // turn it into a date would also make the type check below fail.
+ using var jsonReader = new JsonTextReader(stringReader) { DateParseHandling = DateParseHandling.None };
+ payload = JToken.ReadFrom(jsonReader);
+ }
+ catch (Exception)
+ {
+ error = "Malformed UFC payload";
+ return false;
+ }
+
+ if (payload is not JObject
+ || payload["data"] is not JObject data
+ || data["type"]?.Type != JTokenType.String
+ || data["type"]?.Value() != ResourceType)
+ {
+ error = "Expected a JSON:API Universal Flag Configuration resource";
+ return false;
+ }
+
+ if (data["attributes"] is not JObject attributes
+ || attributes["format"]?.Type != JTokenType.String
+ || attributes["createdAt"]?.Type != JTokenType.String
+ || attributes["environment"] is not JObject environment
+ || environment["name"]?.Type != JTokenType.String
+ || attributes["flags"] is not JObject)
+ {
+ error = "Expected a Universal Flag Configuration v1 object";
+ return false;
+ }
+
+ try
+ {
+ configuration = attributes.ToObject();
+ }
+ catch (Exception)
+ {
+ configuration = null;
+ }
+
+ if (configuration is null)
+ {
+ error = "Expected a Universal Flag Configuration v1 object";
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/AgentlessConfigurationSourceTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/AgentlessConfigurationSourceTests.cs
new file mode 100644
index 000000000000..e2e39a15acde
--- /dev/null
+++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/AgentlessConfigurationSourceTests.cs
@@ -0,0 +1,285 @@
+//
+// 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.IO.Compression;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Datadog.Trace.Agent;
+using Datadog.Trace.FeatureFlags.Agentless;
+using Datadog.Trace.FeatureFlags.Rcm.Model;
+using Datadog.Trace.TestHelpers.TransportHelpers;
+using FluentAssertions;
+using Xunit;
+
+namespace Datadog.Trace.Tests.FeatureFlags;
+
+public class AgentlessConfigurationSourceTests
+{
+ private const string Body = """
+ { "data": { "type": "universal-flag-configuration",
+ "attributes": { "format": "SERVER", "createdAt": "2025-01-01T00:00:00Z",
+ "environment": { "name": "production" }, "flags": {} } } }
+ """;
+
+ private static readonly Uri Endpoint = new("https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server");
+
+ [Fact]
+ public async Task AppliesConfigurationFromA200()
+ {
+ var applied = new List();
+ var factory = new TestRequestFactory(uri => new TestApiRequest(uri, responseContent: Body));
+ using var source = CreateSource(factory, applied);
+
+ await source.PollAsync();
+
+ applied.Should().ContainSingle();
+ applied[0].Environment!.Name.Should().Be("production");
+ factory.RequestsSent.Should().ContainSingle();
+ }
+
+ [Fact]
+ public async Task SendsTheEtagOfTheLastAppliedConfiguration()
+ {
+ var applied = new List();
+ var factory = new TestRequestFactory(
+ uri => new TestApiRequest(uri, responseContent: Body, responseHeaders: new() { { "ETag", "\"ufc-v1\"" } }));
+ using var source = CreateSource(factory, applied);
+
+ await source.PollAsync();
+ applied.Should().ContainSingle();
+
+ // Second poll should send If-None-Match
+ await source.PollAsync();
+ factory.RequestsSent.Should().HaveCount(2);
+ factory.RequestsSent[1].ExtraHeaders.Should().ContainKey("If-None-Match");
+ factory.RequestsSent[1].ExtraHeaders["If-None-Match"].Should().Be("\"ufc-v1\"");
+ }
+
+ [Fact]
+ public async Task DoesNotApplyOn304()
+ {
+ var applied = new List();
+ var factory = new TestRequestFactory(
+ uri => new TestApiRequest(uri, statusCode: 304, responseContent: "{}"));
+ using var source = CreateSource(factory, applied);
+
+ await source.PollAsync();
+
+ applied.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task DoesNotApplyOn401()
+ {
+ var applied = new List();
+ var factory = new TestRequestFactory(
+ uri => new TestApiRequest(uri, statusCode: 401, responseContent: "Unauthorized"));
+ using var source = CreateSource(factory, applied);
+
+ await source.PollAsync();
+
+ applied.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task DoesNotApplyOnMalformedPayload()
+ {
+ var applied = new List();
+ var factory = new TestRequestFactory(
+ uri => new TestApiRequest(uri, statusCode: 200, responseContent: "not json"));
+ using var source = CreateSource(factory, applied);
+
+ await source.PollAsync();
+
+ applied.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task DoesNotApplyAfterDisposal()
+ {
+ var applied = new List();
+ var factory = new TestRequestFactory(uri => new TestApiRequest(uri, responseContent: Body));
+ var source = CreateSource(factory, applied);
+
+ // A shutdown mid-poll leaves the response unusable for a state transition.
+ source.Dispose();
+ await source.PollAsync();
+
+ factory.RequestsSent.Should().ContainSingle();
+ applied.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task DoesNotApplyWhenDisposedAfterRequestSucceeds()
+ {
+ var applied = new List();
+ AgentlessConfigurationSource? sourceRef = null;
+ var factory = new TestRequestFactory(uri =>
+ {
+ var request = new DisposingApiRequest(uri, Body);
+ request.Source = sourceRef;
+ return request;
+ });
+ using var source = CreateSource(factory, applied);
+ sourceRef = source;
+
+ await source.PollAsync();
+
+ applied.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task RetriesOn500ThenAppliesOnSuccess()
+ {
+ var applied = new List();
+ var factory = new TestRequestFactory(
+ uri => new TestApiRequest(uri, statusCode: 500, responseContent: "error"),
+ uri => new TestApiRequest(uri, responseContent: Body));
+ using var source = CreateSource(factory, applied);
+
+ await source.PollAsync();
+
+ applied.Should().ContainSingle();
+ factory.RequestsSent.Should().HaveCount(2);
+ }
+
+ [Fact]
+ public async Task RetriesUpToMaxAttemptsOn500()
+ {
+ var applied = new List();
+ var factory = new TestRequestFactory(
+ uri => new TestApiRequest(uri, statusCode: 500, responseContent: "error"),
+ uri => new TestApiRequest(uri, statusCode: 500, responseContent: "error"),
+ uri => new TestApiRequest(uri, statusCode: 500, responseContent: "error"));
+ using var source = CreateSource(factory, applied);
+
+ await source.PollAsync();
+
+ applied.Should().BeEmpty();
+ factory.RequestsSent.Should().HaveCount(3);
+ }
+
+ [Fact]
+ public async Task DoesNotRetryOn400()
+ {
+ var applied = new List();
+ var factory = new TestRequestFactory(
+ uri => new TestApiRequest(uri, statusCode: 400, responseContent: "bad request"));
+ using var source = CreateSource(factory, applied);
+
+ await source.PollAsync();
+
+ applied.Should().BeEmpty();
+ factory.RequestsSent.Should().ContainSingle();
+ }
+
+ [Fact]
+ public async Task HandlesGzipResponse()
+ {
+ var applied = new List();
+ var factory = new TestRequestFactory(uri => new GzipApiRequest(uri, Body));
+ using var source = CreateSource(factory, applied);
+
+ await source.PollAsync();
+
+ applied.Should().ContainSingle();
+ applied[0].Environment!.Name.Should().Be("production");
+ }
+
+ [Fact]
+ public async Task HandlesNetworkError()
+ {
+ var applied = new List();
+ var factory = new TestRequestFactory(
+ uri => new ThrowingApiRequest(uri),
+ uri => new ThrowingApiRequest(uri),
+ uri => new ThrowingApiRequest(uri));
+ using var source = CreateSource(factory, applied);
+
+ await source.PollAsync();
+
+ applied.Should().BeEmpty();
+ factory.RequestsSent.Should().HaveCount(3);
+ }
+
+ private static AgentlessConfigurationSource CreateSource(TestRequestFactory factory, List applied)
+ => new(
+ Endpoint,
+ factory,
+ TimeSpan.FromSeconds(30),
+ TimeSpan.FromSeconds(5),
+ configuration =>
+ {
+ applied.Add(configuration);
+ return true;
+ },
+ NoWait);
+
+ private static Task NoWait(TimeSpan delay, CancellationToken cancellationToken) => Task.CompletedTask;
+
+ private class ThrowingApiRequest(Uri endpoint) : TestApiRequest(endpoint)
+ {
+ public override Task GetAsync() => throw new IOException("The connection was refused");
+ }
+
+ private class GzipApiRequest(Uri endpoint, string body) : TestApiRequest(endpoint)
+ {
+ public override Task GetAsync() => Task.FromResult(new GzipApiResponse(body));
+ }
+
+ private class GzipApiResponse(string body) : IApiResponse
+ {
+ public int StatusCode => 200;
+
+ public long ContentLength => -1;
+
+ public string? ContentTypeHeader => "application/json";
+
+ public string? ContentEncodingHeader => "gzip";
+
+ public void Dispose()
+ {
+ }
+
+ public string? GetHeader(string headerName) => null;
+
+ public Encoding GetCharsetEncoding() => Encoding.UTF8;
+
+ public ContentEncodingType GetContentEncodingType() => ContentEncodingType.GZip;
+
+ public Task GetStreamAsync()
+ {
+ var compressed = new MemoryStream();
+ using (var gzip = new GZipStream(compressed, CompressionMode.Compress, leaveOpen: true))
+ {
+ var bytes = Encoding.UTF8.GetBytes(body);
+ gzip.Write(bytes, 0, bytes.Length);
+ }
+
+ compressed.Position = 0;
+ return Task.FromResult(compressed);
+ }
+ }
+
+ private class DisposingApiRequest(Uri endpoint, string body) : TestApiRequest(endpoint, responseContent: body)
+ {
+ public AgentlessConfigurationSource? Source { get; set; }
+
+ public override Task GetAsync()
+ {
+ var response = base.GetAsync();
+ // Simulate a shutdown arriving after the request completes but before ApplyAsync.
+ Source?.Dispose();
+ return response;
+ }
+ }
+}
diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/UfcConfigurationParserTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/UfcConfigurationParserTests.cs
new file mode 100644
index 000000000000..b57c9568ebcd
--- /dev/null
+++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/UfcConfigurationParserTests.cs
@@ -0,0 +1,112 @@
+//
+// 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;
+using Datadog.Trace.FeatureFlags.Agentless;
+using Datadog.Trace.FeatureFlags.Rcm.Model;
+using FluentAssertions;
+using Xunit;
+
+namespace Datadog.Trace.Tests.FeatureFlags;
+
+public class UfcConfigurationParserTests
+{
+ private const string ValidEnvelope = """
+ { "data": { "type": "universal-flag-configuration",
+ "attributes": { "format": "SERVER", "createdAt": "2025-01-01T00:00:00Z",
+ "environment": { "name": "production" }, "flags": {} } } }
+ """;
+
+ private const string Attributes = """
+ { "format": "SERVER", "createdAt": "2025-01-01T00:00:00Z",
+ "environment": { "name": "production" }, "flags": {} }
+ """;
+
+ [Fact]
+ public void ParsesValidEnvelope()
+ {
+ UfcConfigurationParser.TryParse(ValidEnvelope, out var configuration, out var error)
+ .Should().BeTrue();
+
+ error.Should().BeNull();
+ configuration.Should().NotBeNull();
+ configuration!.Environment!.Name.Should().Be("production");
+ configuration.Flags.Should().BeEmpty();
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("not json")]
+ [InlineData("{ \"data\": ")]
+ public void RejectsMalformedJson(string? body)
+ {
+ UfcConfigurationParser.TryParse(body, out var configuration, out var error).Should().BeFalse();
+
+ configuration.Should().BeNull();
+ error.Should().Be("Malformed UFC payload");
+ }
+
+ [Theory]
+ // A raw UFC document is rejected too, so every source agrees on one wire format.
+ [InlineData(Attributes)]
+ // Wrong resource type
+ [InlineData("""{ "data": { "type": "wrong-type", "attributes": { "format": "SERVER", "createdAt": "x", "environment": { "name": "prod" }, "flags": {} } } }""")]
+ // Missing data
+ [InlineData("""{ "meta": {} }""")]
+ // data is not an object
+ [InlineData("""{ "data": "string" }""")]
+ // data.type is not a string (object)
+ [InlineData("""{ "data": { "type": { "nested": true }, "attributes": { "format": "SERVER", "createdAt": "x", "environment": { "name": "prod" }, "flags": {} } } }""")]
+ // data.type is not a string (array)
+ [InlineData("""{ "data": { "type": [1, 2], "attributes": { "format": "SERVER", "createdAt": "x", "environment": { "name": "prod" }, "flags": {} } } }""")]
+ public void RejectsInvalidEnvelope(string body)
+ {
+ UfcConfigurationParser.TryParse(body, out var configuration, out var error).Should().BeFalse();
+
+ configuration.Should().BeNull();
+ error.Should().Be("Expected a JSON:API Universal Flag Configuration resource");
+ }
+
+ [Theory]
+ // Missing format
+ [InlineData("""{ "data": { "type": "universal-flag-configuration", "attributes": { "createdAt": "x", "environment": { "name": "prod" }, "flags": {} } } }""")]
+ // Missing createdAt
+ [InlineData("""{ "data": { "type": "universal-flag-configuration", "attributes": { "format": "SERVER", "environment": { "name": "prod" }, "flags": {} } } }""")]
+ // Missing environment
+ [InlineData("""{ "data": { "type": "universal-flag-configuration", "attributes": { "format": "SERVER", "createdAt": "x", "flags": {} } } }""")]
+ // environment.name is not a string
+ [InlineData("""{ "data": { "type": "universal-flag-configuration", "attributes": { "format": "SERVER", "createdAt": "x", "environment": { "name": 123 }, "flags": {} } } }""")]
+ // Missing flags
+ [InlineData("""{ "data": { "type": "universal-flag-configuration", "attributes": { "format": "SERVER", "createdAt": "x", "environment": { "name": "prod" } } } }""")]
+ // flags is not an object
+ [InlineData("""{ "data": { "type": "universal-flag-configuration", "attributes": { "format": "SERVER", "createdAt": "x", "environment": { "name": "prod" }, "flags": [] } } }""")]
+ public void RejectsInvalidAttributes(string body)
+ {
+ UfcConfigurationParser.TryParse(body, out var configuration, out var error).Should().BeFalse();
+
+ configuration.Should().BeNull();
+ error.Should().Be("Expected a Universal Flag Configuration v1 object");
+ }
+
+ [Fact]
+ public void ParsesFlagsFromEnvelope()
+ {
+ var body = """
+ { "data": { "type": "universal-flag-configuration",
+ "attributes": { "format": "SERVER", "createdAt": "2025-01-01T00:00:00Z",
+ "environment": { "name": "production" },
+ "flags": { "test-flag": { "key": "test-flag", "enabled": true, "variationType": "BOOLEAN" } } } } }
+ """;
+
+ UfcConfigurationParser.TryParse(body, out var configuration, out _).Should().BeTrue();
+
+ configuration!.Flags.Should().NotBeNull();
+ configuration!.Flags!.Should().ContainKey("test-flag");
+ configuration!.Flags!["test-flag"].Enabled.Should().BeTrue();
+ }
+}