diff --git a/tracer/src/Datadog.Trace.Trimming/build/Datadog.Trace.Trimming.xml b/tracer/src/Datadog.Trace.Trimming/build/Datadog.Trace.Trimming.xml
index 8925d38839bb..0c6e862295df 100644
--- a/tracer/src/Datadog.Trace.Trimming/build/Datadog.Trace.Trimming.xml
+++ b/tracer/src/Datadog.Trace.Trimming/build/Datadog.Trace.Trimming.xml
@@ -930,6 +930,7 @@
+
diff --git a/tracer/src/Datadog.Trace/Agent/AgentTransportStrategy.cs b/tracer/src/Datadog.Trace/Agent/AgentTransportStrategy.cs
index 9bff5f2cbd17..55a1be9ee015 100644
--- a/tracer/src/Datadog.Trace/Agent/AgentTransportStrategy.cs
+++ b/tracer/src/Datadog.Trace/Agent/AgentTransportStrategy.cs
@@ -31,12 +31,14 @@ internal static class AgentTransportStrategy
/// A func that returns the endpoint to send requests to for a given "base" endpoint.
/// The base endpoint will be for TCP requests and
/// http://localhost/ for named pipes/UDS if null, the default base endpoint is used
+ /// Whether HTTP handlers may follow redirects. Stream transports never follow them.
public static IApiRequestFactory Get(
ExporterSettings settings,
string productName,
TimeSpan? tcpTimeout,
HttpHeaderHelperBase httpHeaderHelper,
- Func? getBaseEndpoint = null)
+ Func? getBaseEndpoint = null,
+ bool allowAutoRedirect = true)
{
var strategy = settings.TracesTransport;
@@ -56,7 +58,8 @@ public static IApiRequestFactory Get(
return new SocketHandlerRequestFactory(
new UnixDomainSocketStreamFactory(settings.TracesUnixDomainSocketPath),
httpHeaderHelper.DefaultHeaders,
- getBaseEndpoint?.Invoke(Localhost) ?? Localhost);
+ getBaseEndpoint?.Invoke(Localhost) ?? Localhost,
+ allowAutoRedirect: allowAutoRedirect);
#elif NETCOREAPP3_1_OR_GREATER
Log.Information("Using " + nameof(UnixDomainSocketStreamFactory) + " for {ProductName} transport, with Unix Domain Sockets path {TracesUnixDomainSocketPath} and timeout {TracesPipeTimeoutMs}ms.", productName, settings.TracesUnixDomainSocketPath, settings.TracesPipeTimeoutMs);
return new HttpStreamRequestFactory(
@@ -74,13 +77,15 @@ public static IApiRequestFactory Get(
return new HttpClientRequestFactory(
getBaseEndpoint?.Invoke(settings.AgentUri) ?? settings.AgentUri,
httpHeaderHelper.DefaultHeaders,
- timeout: tcpTimeout);
+ timeout: tcpTimeout,
+ allowAutoRedirect: allowAutoRedirect);
#else
Log.Information("Using " + nameof(ApiWebRequestFactory) + " for {ProductName} transport.", productName);
return new ApiWebRequestFactory(
getBaseEndpoint?.Invoke(settings.AgentUri) ?? settings.AgentUri,
httpHeaderHelper.DefaultHeaders,
- timeout: tcpTimeout);
+ timeout: tcpTimeout,
+ allowAutoRedirect: allowAutoRedirect);
#endif
}
}
diff --git a/tracer/src/Datadog.Trace/Agent/Transports/ApiWebRequestFactory.cs b/tracer/src/Datadog.Trace/Agent/Transports/ApiWebRequestFactory.cs
index 46160bba2f3e..dab42dd8b2f4 100644
--- a/tracer/src/Datadog.Trace/Agent/Transports/ApiWebRequestFactory.cs
+++ b/tracer/src/Datadog.Trace/Agent/Transports/ApiWebRequestFactory.cs
@@ -17,17 +17,21 @@ internal sealed class ApiWebRequestFactory : IApiRequestFactory
{
private readonly KeyValuePair[] _defaultHeaders;
private readonly Uri _baseEndpoint;
+ private readonly bool _allowAutoRedirect;
private WebProxy _proxy;
private NetworkCredential _credential;
private TimeSpan? _timeout;
- public ApiWebRequestFactory(Uri baseEndpoint, KeyValuePair[] defaultHeaders, TimeSpan? timeout = null)
+ public ApiWebRequestFactory(Uri baseEndpoint, KeyValuePair[] defaultHeaders, TimeSpan? timeout = null, bool allowAutoRedirect = true)
{
_baseEndpoint = baseEndpoint;
_defaultHeaders = defaultHeaders;
_timeout = timeout;
+ _allowAutoRedirect = allowAutoRedirect;
}
+ internal bool AllowAutoRedirect => _allowAutoRedirect;
+
public string Info(Uri endpoint)
{
return endpoint.ToString();
@@ -38,6 +42,7 @@ public string Info(Uri endpoint)
public IApiRequest Create(Uri endpoint)
{
var request = WebRequest.CreateHttp(endpoint);
+ request.AllowAutoRedirect = _allowAutoRedirect;
if (_proxy is not null)
{
request.Proxy = _proxy;
diff --git a/tracer/src/Datadog.Trace/Agent/Transports/HttpClientRequestFactory.cs b/tracer/src/Datadog.Trace/Agent/Transports/HttpClientRequestFactory.cs
index a6b5b8360e89..26f1b409ce0f 100644
--- a/tracer/src/Datadog.Trace/Agent/Transports/HttpClientRequestFactory.cs
+++ b/tracer/src/Datadog.Trace/Agent/Transports/HttpClientRequestFactory.cs
@@ -1,4 +1,4 @@
-//
+//
// 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.
//
@@ -22,9 +22,9 @@ internal sealed class HttpClientRequestFactory : IApiRequestFactory
private readonly HttpMessageHandler _handler;
private readonly Uri _baseEndpoint;
- public HttpClientRequestFactory(Uri baseEndpoint, KeyValuePair[] defaultHeaders, HttpMessageHandler handler = null, TimeSpan? timeout = null)
+ public HttpClientRequestFactory(Uri baseEndpoint, KeyValuePair[] defaultHeaders, HttpMessageHandler handler = null, TimeSpan? timeout = null, bool allowAutoRedirect = true)
{
- _handler = handler ?? new HttpClientHandler();
+ _handler = handler ?? new HttpClientHandler { AllowAutoRedirect = allowAutoRedirect };
_client = new HttpClient(_handler);
_baseEndpoint = baseEndpoint;
if (timeout.HasValue)
@@ -41,6 +41,15 @@ public HttpClientRequestFactory(Uri baseEndpoint, KeyValuePair[]
_client.DefaultRequestHeaders.ConnectionClose = true;
}
+ internal bool AllowAutoRedirect => _handler switch
+ {
+ HttpClientHandler handler => handler.AllowAutoRedirect,
+#if NET5_0_OR_GREATER
+ SocketsHttpHandler handler => handler.AllowAutoRedirect,
+#endif
+ _ => true,
+ };
+
public Uri GetEndpoint(string relativePath) => relativePath is null ? _baseEndpoint : UriHelpers.Combine(_baseEndpoint, relativePath);
#if NET5_0_OR_GREATER // in .NET 6 we derive a SocketHandlerRequestFactory
diff --git a/tracer/src/Datadog.Trace/Agent/Transports/SocketHandlerRequestFactory.cs b/tracer/src/Datadog.Trace/Agent/Transports/SocketHandlerRequestFactory.cs
index 6bcbd1427dd4..b0cd23a73628 100644
--- a/tracer/src/Datadog.Trace/Agent/Transports/SocketHandlerRequestFactory.cs
+++ b/tracer/src/Datadog.Trace/Agent/Transports/SocketHandlerRequestFactory.cs
@@ -1,4 +1,4 @@
-//
+//
// 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.
//
@@ -15,7 +15,7 @@ internal sealed class SocketHandlerRequestFactory : HttpClientRequestFactory
{
private readonly IStreamFactory _streamFactory;
- public SocketHandlerRequestFactory(IStreamFactory streamFactory, KeyValuePair[] defaultHeaders, Uri baseEndpoint, TimeSpan? timeout = null)
+ public SocketHandlerRequestFactory(IStreamFactory streamFactory, KeyValuePair[] defaultHeaders, Uri baseEndpoint, TimeSpan? timeout = null, bool allowAutoRedirect = true)
: base(
// HttpClient requires a "valid" host header, and will only accept http:// or https:// schemes
// The host part of the endpoint is irrelevant, as we're using the UDS socket/named pipe
@@ -25,6 +25,7 @@ public SocketHandlerRequestFactory(IStreamFactory streamFactory, KeyValuePair await streamFactory.GetBidirectionalStreamAsync(token).ConfigureAwait(false)
})
{
diff --git a/tracer/src/Datadog.Trace/FeatureFlags/Agentless/AgentlessEndpoint.cs b/tracer/src/Datadog.Trace/FeatureFlags/Agentless/AgentlessEndpoint.cs
index 64bda187b754..8dbf9f8777a8 100644
--- a/tracer/src/Datadog.Trace/FeatureFlags/Agentless/AgentlessEndpoint.cs
+++ b/tracer/src/Datadog.Trace/FeatureFlags/Agentless/AgentlessEndpoint.cs
@@ -78,28 +78,12 @@ public static bool TryCreate(string? site, string? baseUrl, [NotNullWhen(true)]
var configured = baseUrl?.Trim();
if (StringUtil.IsNullOrEmpty(configured))
{
- var trimmedSite = site?.Trim();
- if (StringUtil.IsNullOrEmpty(trimmedSite))
+ if (!TryNormalizeSite(site, out var normalizedSite, out error))
{
- error = "No Datadog site is configured";
return false;
}
- // The site is concatenated into a host, so every character that can change what a URL means
- // has to be rejected before that happens. "@" is the dangerous one: it would make the rest of
- // the value the real host, and the API key would be sent there. "/", "?" and "#" would start a
- // path, query or fragment, and ":" a port or a scheme. Uri.TryCreate accepts several of these,
- // so it cannot be relied on to catch them. The other tracers reject the same set.
- foreach (var character in trimmedSite)
- {
- if (char.IsWhiteSpace(character) || character is '/' or '?' or '#' or '@' or ':')
- {
- error = "The configured Datadog site is not valid";
- return false;
- }
- }
-
- var managedHost = ManagedHostPrefix + StringUtil.ToLowerInvariant(trimmedSite);
+ var managedHost = ManagedHostPrefix + normalizedSite;
if (!Uri.TryCreate($"https://{managedHost}{DefaultPath}", UriKind.Absolute, out var managedUri))
{
@@ -144,6 +128,84 @@ public static bool TryCreate(string? site, string? baseUrl, [NotNullWhen(true)]
return true;
}
+ ///
+ /// Validates and normalizes a Datadog site before it is appended to a managed hostname.
+ /// Configuration and event delivery use this same method so credentials cannot be routed by
+ /// two subtly different parsers.
+ ///
+ internal static bool TryNormalizeSite(string? site, out string normalizedSite, out string? error)
+ {
+ normalizedSite = string.Empty;
+ error = null;
+
+ var trimmedSite = site?.Trim();
+ if (StringUtil.IsNullOrEmpty(trimmedSite))
+ {
+ error = "No Datadog site is configured";
+ return false;
+ }
+
+ // The complete managed host must remain below the DNS limit once either the configuration
+ // or event-intake prefix is applied.
+ if (trimmedSite.Length > 230)
+ {
+ error = "The configured Datadog site is not valid";
+ return false;
+ }
+
+ // Accept only DNS label characters before concatenating the site into a managed host.
+ // Uri parsing alone accepts delimiters such as '@' that can redirect the API key to a
+ // different host, as well as '/', '?', '#', and ':' that change the URL's meaning.
+ var labelLength = 0;
+ var previousWasHyphen = false;
+ foreach (var character in trimmedSite)
+ {
+ if (character == '.')
+ {
+ if (labelLength == 0 || previousWasHyphen)
+ {
+ error = "The configured Datadog site is not valid";
+ return false;
+ }
+
+ labelLength = 0;
+ previousWasHyphen = false;
+ continue;
+ }
+
+ if (character is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9')
+ {
+ labelLength++;
+ previousWasHyphen = false;
+ }
+ else if (character == '-' && labelLength > 0)
+ {
+ labelLength++;
+ previousWasHyphen = true;
+ }
+ else
+ {
+ error = "The configured Datadog site is not valid";
+ return false;
+ }
+
+ if (labelLength > 63)
+ {
+ error = "The configured Datadog site is not valid";
+ return false;
+ }
+ }
+
+ if (labelLength == 0 || previousWasHyphen)
+ {
+ error = "The configured Datadog site is not valid";
+ return false;
+ }
+
+ normalizedSite = StringUtil.ToLowerInvariant(trimmedSite);
+ return true;
+ }
+
///
/// Returns the URI to request configuration for . The environment is
/// added as a query parameter rather than baked into the endpoint, because it can change while
diff --git a/tracer/src/Datadog.Trace/FeatureFlags/Evp/FeatureFlagsEvpHeaderHelper.cs b/tracer/src/Datadog.Trace/FeatureFlags/Evp/FeatureFlagsEvpHeaderHelper.cs
new file mode 100644
index 000000000000..24c6726ce1c4
--- /dev/null
+++ b/tracer/src/Datadog.Trace/FeatureFlags/Evp/FeatureFlagsEvpHeaderHelper.cs
@@ -0,0 +1,45 @@
+//
+// 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.HttpOverStreams;
+
+namespace Datadog.Trace.FeatureFlags.Evp;
+
+///
+/// Headers used when a Feature Flags event is delivered through a local EVP relay.
+///
+internal sealed class FeatureFlagsEvpHeaderHelper : HttpHeaderHelperBase
+{
+ internal const string EvpSubdomainHeader = "X-Datadog-EVP-Subdomain";
+ internal const string EvpSubdomain = "event-platform-intake";
+ internal const string EvpOriginHeader = "DD-EVP-ORIGIN";
+ internal const string EvpOrigin = "dd-trace-dotnet";
+ internal const string EvpOriginVersionHeader = "DD-EVP-ORIGIN-VERSION";
+
+ public static readonly FeatureFlagsEvpHeaderHelper Instance = new();
+
+ private FeatureFlagsEvpHeaderHelper()
+ {
+ DefaultHeaders =
+ [
+ .. AgentHttpHeaderNames.MinimalHeaders,
+ new(EvpSubdomainHeader, EvpSubdomain),
+ new(EvpOriginHeader, EvpOrigin),
+ new(EvpOriginVersionHeader, TracerConstants.ThreePartVersion),
+ ];
+ HttpSerializedDefaultHeaders =
+ $"{AgentHttpHeaderNames.HttpSerializedMinimalHeaders}" +
+ $"{EvpSubdomainHeader}: {EvpSubdomain}{DatadogHttpValues.CrLf}" +
+ $"{EvpOriginHeader}: {EvpOrigin}{DatadogHttpValues.CrLf}" +
+ $"{EvpOriginVersionHeader}: {TracerConstants.ThreePartVersion}{DatadogHttpValues.CrLf}";
+ }
+
+ public override KeyValuePair[] DefaultHeaders { get; }
+
+ protected override string HttpSerializedDefaultHeaders { get; }
+}
diff --git a/tracer/src/Datadog.Trace/FeatureFlags/Evp/FeatureFlagsEvpTransport.cs b/tracer/src/Datadog.Trace/FeatureFlags/Evp/FeatureFlagsEvpTransport.cs
index ecceeda61580..807db77c6d78 100644
--- a/tracer/src/Datadog.Trace/FeatureFlags/Evp/FeatureFlagsEvpTransport.cs
+++ b/tracer/src/Datadog.Trace/FeatureFlags/Evp/FeatureFlagsEvpTransport.cs
@@ -6,79 +6,606 @@
#nullable enable
using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Net;
+#if NETCOREAPP
+using System.Net.Http;
+#endif
+using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using Datadog.Trace.Agent;
+using Datadog.Trace.Agent.DiscoveryService;
using Datadog.Trace.Agent.Transports;
using Datadog.Trace.Configuration;
+using Datadog.Trace.FeatureFlags.Agentless;
using Datadog.Trace.HttpOverStreams;
+using Datadog.Trace.Logging;
using Datadog.Trace.SourceGenerators;
+using Datadog.Trace.Telemetry;
using Datadog.Trace.Vendors.Newtonsoft.Json;
namespace Datadog.Trace.FeatureFlags.Evp;
///
-/// Owns the historical fixed-v2 Feature Flags event sender independently of exposure batching.
+/// Selects and sends to a Feature Flags EVP route without changing the product payload.
///
internal sealed class FeatureFlagsEvpTransport : IDisposable
{
internal const string ExposureIntakePath = "api/v2/exposures";
+ internal const string FlagEvaluationIntakePath = "api/v2/flagevaluation";
+ internal const string EventPlatformProxyV4 = "evp_proxy/v4";
internal const string EventPlatformProxyV2 = "evp_proxy/v2";
- private readonly object _settingsLock = new();
+ private static readonly TimeSpan InitialDiscoveryWait = TimeSpan.FromSeconds(5);
+ private static readonly TimeSpan RouteRecoveryCooldown = TimeSpan.FromSeconds(30);
+ private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(typeof(FeatureFlagsEvpTransport));
+
+ private readonly FeatureFlagsSource _source;
+ private readonly IApiRequestFactory? _directRequestFactory;
+ private readonly IDiscoveryService _discoveryService;
+ private readonly Action _discoveryCallback;
+ private readonly Action? _warningSink;
private readonly IDisposable? _settingsSubscription;
- private IApiRequestFactory _localRequestFactory;
+ private readonly TimeSpan _initialDiscoveryWait;
+ private readonly TimeSpan _routeRecoveryCooldown;
+ private readonly Func _utcNow;
+ private readonly bool _discoverySubscribed;
+ private readonly object _settingsLock = new();
+ private readonly IDatadogLogger _log = Log;
+
+ private LocalEndpoint _localEndpoint;
+ private int _directIsSticky;
private int _disposed;
+ private int _localRecoveryProbeInProgress;
+ private int _unavailableWarningLogged;
+ private long _localUnavailableUntilUtcTicks;
- internal FeatureFlagsEvpTransport(TracerSettings settings)
+ internal FeatureFlagsEvpTransport(TracerSettings settings, IDiscoveryService discoveryService, Action? warningSink = null)
{
- _localRequestFactory = CreateLocalRequestFactory(settings.Manager.InitialExporterSettings);
+ _source = settings.FeatureFlags.Source;
+ var exporter = settings.Manager.InitialExporterSettings;
+ _localEndpoint = new(CreateLocalRequestFactory(exporter, _source == FeatureFlagsSource.RemoteConfig), exporter);
+ _discoveryService = discoveryService;
+ _discoveryCallback = UpdateAgentConfiguration;
+ _warningSink = warningSink;
+ _initialDiscoveryWait = InitialDiscoveryWait;
+ _routeRecoveryCooldown = RouteRecoveryCooldown;
+ _utcNow = static () => DateTimeOffset.UtcNow;
+
+ if (_source == FeatureFlagsSource.Agentless)
+ {
+ _directRequestFactory = CreateDirectRequestFactory(settings.FeatureFlags, out var invalidSite);
+ if (invalidSite)
+ {
+ WarnInvalidSite();
+ }
+
+ // The shared discovery service owns its own bounded retry/backoff loop. Event flushes
+ // wait for its first result once and then consume later callbacks; they never start an
+ // independent /info request on every flush.
+ if (discoveryService is NullDiscoveryService)
+ {
+ _localEndpoint.Discovery.TrySetResult(false);
+ }
+ else
+ {
+ _discoveryService.SubscribeToChanges(_discoveryCallback);
+ _discoverySubscribed = true;
+ }
+ }
+ else if (_source == FeatureFlagsSource.RemoteConfig)
+ {
+ // Preserve the historical Remote Config transport: fixed EVP v2, no /info discovery,
+ // and no direct credentials.
+ _localEndpoint.ProxyEndpoint = EventPlatformProxyV2;
+ _localEndpoint.Discovery.TrySetResult(true);
+ }
+
_settingsSubscription = settings.Manager.SubscribeToChanges(changes =>
{
- if (changes.UpdatedExporter is { } exporter)
+ if (changes.UpdatedExporter is { } updatedExporter)
{
- lock (_settingsLock)
- {
- if (_disposed == 0)
- {
- Interlocked.Exchange(ref _localRequestFactory, CreateLocalRequestFactory(exporter));
- }
- }
+ UpdateExporter(updatedExporter);
}
});
}
[TestingOnly]
- internal FeatureFlagsEvpTransport(IApiRequestFactory localRequestFactory)
+ internal FeatureFlagsEvpTransport(
+ FeatureFlagsSource source,
+ IApiRequestFactory localRequestFactory,
+ IApiRequestFactory? directRequestFactory,
+ IDiscoveryService discoveryService,
+ string? initialLocalProxyEndpoint = null,
+ bool initialDiscoveryKnown = true,
+ TimeSpan? initialDiscoveryWait = null,
+ TimeSpan? routeRecoveryCooldown = null,
+ Func? utcNow = null,
+ Action? warningSink = null,
+ ExporterSettings? exporterSettings = null,
+ IDatadogLogger? logger = null)
+ {
+ _source = source;
+ _localEndpoint = new(localRequestFactory, exporterSettings);
+ _log = logger ?? Log;
+ _directRequestFactory = source == FeatureFlagsSource.Agentless ? directRequestFactory : null;
+ _discoveryService = discoveryService;
+ _discoveryCallback = UpdateAgentConfiguration;
+ _warningSink = warningSink;
+ _initialDiscoveryWait = initialDiscoveryWait ?? InitialDiscoveryWait;
+ _routeRecoveryCooldown = routeRecoveryCooldown ?? RouteRecoveryCooldown;
+ _utcNow = utcNow ?? (static () => DateTimeOffset.UtcNow);
+
+ if (source == FeatureFlagsSource.RemoteConfig)
+ {
+ _localEndpoint.ProxyEndpoint = EventPlatformProxyV2;
+ _localEndpoint.Discovery.TrySetResult(true);
+ }
+ else
+ {
+ _localEndpoint.ProxyEndpoint = initialLocalProxyEndpoint;
+ if (initialDiscoveryKnown || discoveryService is NullDiscoveryService)
+ {
+ _localEndpoint.Discovery.TrySetResult(true);
+ }
+
+ if (discoveryService is not NullDiscoveryService)
+ {
+ _discoveryService.SubscribeToChanges(_discoveryCallback);
+ _discoverySubscribed = true;
+ }
+ }
+ }
+
+ private enum NetworkFailure
{
- _localRequestFactory = localRequestFactory;
+ None,
+ DefinitivePreSend,
+ Ambiguous,
+ }
+
+ internal static KeyValuePair[] GetDirectHeaders(string apiKey) =>
+ [
+ new(TelemetryConstants.ApiKeyHeader, apiKey),
+ new(FeatureFlagsEvpHeaderHelper.EvpOriginHeader, FeatureFlagsEvpHeaderHelper.EvpOrigin),
+ new(FeatureFlagsEvpHeaderHelper.EvpOriginVersionHeader, TracerConstants.ThreePartVersion),
+ new(HttpHeaderNames.TracingEnabled, "false"),
+ ];
+
+ [SuppressMessage("Performance", "CA1859:Use concrete types when possible for improved performance", Justification = "Different implementation types are returned for different TFMs")]
+ internal static IApiRequestFactory? CreateDirectRequestFactory(FeatureFlagsSettings settings)
+ => CreateDirectRequestFactory(settings, out _);
+
+ [SuppressMessage("Performance", "CA1859:Use concrete types when possible for improved performance", Justification = "Different implementation types are returned for different TFMs")]
+ private static IApiRequestFactory? CreateDirectRequestFactory(FeatureFlagsSettings settings, out bool invalidSite)
+ {
+ invalidSite = false;
+ if (string.IsNullOrWhiteSpace(settings.ApiKey))
+ {
+ return null;
+ }
+
+ if (!AgentlessEndpoint.TryNormalizeSite(settings.Site, out var site, out _))
+ {
+ invalidSite = true;
+ return null;
+ }
+
+ var expectedHost = $"event-platform-intake.{site}";
+ if (!Uri.TryCreate($"https://{expectedHost}", UriKind.Absolute, out var endpoint)
+ || endpoint.Scheme != Uri.UriSchemeHttps
+ || !string.Equals(endpoint.IdnHost, expectedHost, StringComparison.Ordinal))
+ {
+ invalidSite = true;
+ return null;
+ }
+
+ var headers = GetDirectHeaders(settings.ApiKey!);
+#if NETCOREAPP
+ // The default HttpClientHandler honours the runtime's HTTPS_PROXY and NO_PROXY settings.
+ // Redirects stay disabled so the API key is sent only to the configured intake host.
+ return new HttpClientRequestFactory(endpoint, headers, timeout: TimeSpan.FromSeconds(5), allowAutoRedirect: false);
+#else
+ // HttpWebRequest uses the platform default proxy and bypass list. Redirects stay disabled
+ // so the API key is sent only to the configured intake host.
+ return new ApiWebRequestFactory(endpoint, headers, timeout: TimeSpan.FromSeconds(5), allowAutoRedirect: false);
+#endif
}
[TestingAndPrivateOnly]
- internal static IApiRequestFactory CreateLocalRequestFactory(ExporterSettings exporterSettings)
+ internal static IApiRequestFactory CreateLocalRequestFactory(ExporterSettings exporterSettings, bool allowAutoRedirect = false)
=> AgentTransportStrategy.Get(
exporterSettings,
- productName: "FeatureFlags exposure",
+ productName: "Feature Flags EVP",
tcpTimeout: TimeSpan.FromSeconds(5),
- httpHeaderHelper: EventPlatformHeaderHelper.Instance);
+ httpHeaderHelper: FeatureFlagsEvpHeaderHelper.Instance,
+ allowAutoRedirect: allowAutoRedirect);
+
+ [TestingAndPrivateOnly]
+ internal static bool IsDefinitivePreSendSocketFailure(SocketException exception)
+ => exception.SocketErrorCode is SocketError.HostNotFound
+ or SocketError.TryAgain
+ or SocketError.ConnectionRefused
+ or SocketError.NetworkUnreachable
+ or SocketError.HostUnreachable
+ or SocketError.AddressNotAvailable
+ || exception.ErrorCode == 2 // ENOENT for a missing Unix domain socket
+ || exception.ErrorCode == 10061; // WSAECONNREFUSED
+
+ private static NetworkFailure ClassifyNetworkFailure(Exception exception)
+ {
+ var isNetworkFailure = false;
+ for (Exception? current = exception; current is not null; current = current.InnerException)
+ {
+ switch (current)
+ {
+ case SocketException socketException:
+ return IsDefinitivePreSendSocketFailure(socketException)
+ ? NetworkFailure.DefinitivePreSend
+ : NetworkFailure.Ambiguous;
+ case FileNotFoundException:
+ return NetworkFailure.DefinitivePreSend;
+ case WebException webException:
+ switch (webException.Status)
+ {
+ case WebExceptionStatus.ConnectFailure:
+ case WebExceptionStatus.NameResolutionFailure:
+ case WebExceptionStatus.ProxyNameResolutionFailure:
+ return NetworkFailure.DefinitivePreSend;
+ case WebExceptionStatus.ConnectionClosed:
+ case WebExceptionStatus.KeepAliveFailure:
+ case WebExceptionStatus.PipelineFailure:
+ case WebExceptionStatus.ReceiveFailure:
+ case WebExceptionStatus.RequestCanceled:
+ case WebExceptionStatus.SendFailure:
+ case WebExceptionStatus.Timeout:
+ return NetworkFailure.Ambiguous;
+ }
+
+ isNetworkFailure = true;
+ break;
+#if NETCOREAPP
+ case HttpRequestException:
+#endif
+ case IOException:
+ case OperationCanceledException:
+ case TimeoutException:
+ isNetworkFailure = true;
+ break;
+ }
+ }
+
+ return isNetworkFailure ? NetworkFailure.Ambiguous : NetworkFailure.None;
+ }
public void Dispose()
{
- if (Interlocked.Exchange(ref _disposed, 1) == 0)
+ if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
- _settingsSubscription?.Dispose();
+ return;
+ }
+
+ _settingsSubscription?.Dispose();
+ lock (_settingsLock)
+ {
+ _localEndpoint.Discovery.TrySetResult(false);
+ if (_discoverySubscribed)
+ {
+ _discoveryService.RemoveSubscription(_discoveryCallback);
+ }
}
}
- internal async Task SendAsync(T payload, string intakePath, JsonSerializerSettings serializerSettings)
+ internal Task SendAsync(T payload, string intakePath, JsonSerializerSettings serializerSettings)
+ => SendAsync(intakePath, request => request.PostAsJsonAsync(payload, MultipartCompression.GZip, serializerSettings));
+
+ private void UpdateExporter(ExporterSettings exporter)
+ {
+ lock (_settingsLock)
+ {
+ if (_disposed != 0)
+ {
+ return;
+ }
+
+ var replacement = new LocalEndpoint(CreateLocalRequestFactory(exporter, _source == FeatureFlagsSource.RemoteConfig), exporter);
+ if (_source == FeatureFlagsSource.RemoteConfig)
+ {
+ replacement.ProxyEndpoint = EventPlatformProxyV2;
+ replacement.Discovery.TrySetResult(true);
+ }
+ else if (_discoveryService is NullDiscoveryService)
+ {
+ replacement.Discovery.TrySetResult(false);
+ }
+
+ var previous = Interlocked.Exchange(ref _localEndpoint, replacement);
+ previous.Discovery.TrySetResult(false);
+ if (_discoverySubscribed)
+ {
+ // The shared discovery callback may precede this settings callback. Subscribe
+ // again to consume any result already obtained for the new immutable settings.
+ _discoveryService.RemoveSubscription(_discoveryCallback);
+ _discoveryService.SubscribeToChanges(_discoveryCallback);
+ }
+ }
+ }
+
+ private async Task SendAsync(string intakePath, Func> sendAsync)
{
if (Volatile.Read(ref _disposed) != 0)
{
return;
}
- var factory = Volatile.Read(ref _localRequestFactory);
- var request = factory.Create(factory.GetEndpoint($"{EventPlatformProxyV2}/{intakePath}"));
- using var response = await request.PostAsJsonAsync(payload, MultipartCompression.GZip, serializerSettings).ConfigureAwait(false);
+ if (Volatile.Read(ref _directIsSticky) != 0)
+ {
+ await SendDirectAsync(intakePath, sendAsync).ConfigureAwait(false);
+ return;
+ }
+
+ var local = Volatile.Read(ref _localEndpoint);
+ var localProxyEndpoint = local.ProxyEndpoint;
+ if (localProxyEndpoint is not null)
+ {
+ await TrySendLocalAsync(intakePath, local, localProxyEndpoint, sendAsync).ConfigureAwait(false);
+ return;
+ }
+
+ if (_source == FeatureFlagsSource.Agentless)
+ {
+ // Endpoint replacement wakes waiters on the previous generation. Keep waiting
+ // for the replacement's capabilities without extending this batch's deadline.
+ var discoveryDeadline = Task.Delay(_initialDiscoveryWait);
+ do
+ {
+ await Task.WhenAny(local.Discovery.Task, discoveryDeadline).ConfigureAwait(false);
+ if (Volatile.Read(ref _disposed) != 0)
+ {
+ return;
+ }
+
+ local = Volatile.Read(ref _localEndpoint);
+ if (discoveryDeadline.IsCompleted)
+ {
+ local.Discovery.TrySetResult(false);
+ break;
+ }
+ }
+ while (!local.Discovery.Task.IsCompleted);
+ }
+
+ // Use the checked factory snapshot, not a newer unvalidated generation.
+ localProxyEndpoint = local.ProxyEndpoint;
+ if (localProxyEndpoint is not null)
+ {
+ await TrySendLocalAsync(intakePath, local, localProxyEndpoint, sendAsync).ConfigureAwait(false);
+ return;
+ }
+
+ if (_source == FeatureFlagsSource.Agentless && _directRequestFactory is not null)
+ {
+ Interlocked.Exchange(ref _directIsSticky, 1);
+ await SendDirectAsync(intakePath, sendAsync).ConfigureAwait(false);
+ return;
+ }
+
+ if (Interlocked.Exchange(ref _unavailableWarningLogged, 1) == 0)
+ {
+ WarnUnavailable();
+ }
+ }
+
+ private void WarnInvalidSite()
+ {
+ const string Message = "Feature Flags direct event delivery is disabled because DD_SITE is not a valid DNS site suffix";
+ if (_warningSink is { } warningSink)
+ {
+ warningSink(Message);
+ }
+ else
+ {
+ Log.Warning("Feature Flags direct event delivery is disabled because DD_SITE is not a valid DNS site suffix");
+ }
+ }
+
+ private void WarnUnavailable()
+ {
+ const string Message = "Feature Flags event delivery is unavailable because no compatible local EVP route or direct intake credentials are available";
+ if (_warningSink is { } warningSink)
+ {
+ warningSink(Message);
+ }
+ else
+ {
+ Log.Warning("Feature Flags event delivery is unavailable because no compatible local EVP route or direct intake credentials are available");
+ }
+ }
+
+ private void UpdateAgentConfiguration(AgentConfiguration configuration)
+ {
+ var local = Volatile.Read(ref _localEndpoint);
+ if (local.ExporterSettings is not null && !ReferenceEquals(local.ExporterSettings, configuration.DiscoverySettings))
+ {
+ return;
+ }
+
+ // Agentless event delivery requires the Agent to preserve the logical producer identity.
+ // Older Agents can advertise EVP while silently dropping these headers, so use direct
+ // intake instead unless /info explicitly advertises both forwarding capabilities.
+ var advertisedEndpoint = configuration.EventPlatformProxyEndpoint;
+ var endpoint = configuration.EventPlatformProxySupportsEvpOriginHeaders
+ && (string.Equals(advertisedEndpoint, EventPlatformProxyV4, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(advertisedEndpoint, EventPlatformProxyV2, StringComparison.OrdinalIgnoreCase))
+ ? advertisedEndpoint
+ : null;
+
+ local.ProxyEndpoint = endpoint;
+ if (endpoint is not null)
+ {
+ Interlocked.Exchange(ref _localUnavailableUntilUtcTicks, 0);
+ }
+
+ local.Discovery.TrySetResult(true);
+ }
+
+ private async Task TrySendLocalAsync(string intakePath, LocalEndpoint local, string localProxyEndpoint, Func> sendAsync)
+ {
+ var unavailableUntilUtcTicks = Interlocked.Read(ref _localUnavailableUntilUtcTicks);
+ var isRecoveryProbe = unavailableUntilUtcTicks != 0;
+ if (isRecoveryProbe
+ && (_utcNow().UtcTicks < unavailableUntilUtcTicks
+ || Interlocked.CompareExchange(ref _localRecoveryProbeInProgress, 1, 0) != 0))
+ {
+ if (Interlocked.Exchange(ref _unavailableWarningLogged, 1) == 0)
+ {
+ WarnUnavailable();
+ }
+
+ return;
+ }
+
+ try
+ {
+ await SendLocalAsync(intakePath, local.Factory, localProxyEndpoint, sendAsync).ConfigureAwait(false);
+ }
+ finally
+ {
+ if (isRecoveryProbe)
+ {
+ Interlocked.Exchange(ref _localRecoveryProbeInProgress, 0);
+ }
+ }
+ }
+
+ private async Task SendLocalAsync(string intakePath, IApiRequestFactory localFactory, string localProxyEndpoint, Func> sendAsync)
+ {
+ var endpoint = localFactory.GetEndpoint($"{localProxyEndpoint}/{intakePath}");
+
+ try
+ {
+ var request = localFactory.Create(endpoint);
+ using var response = await sendAsync(request).ConfigureAwait(false);
+ if (response.StatusCode is >= 200 and < 300)
+ {
+ Interlocked.Exchange(ref _localUnavailableUntilUtcTicks, 0);
+ return;
+ }
+
+ // The Agent contract proves these statuses mean the proxy route did not accept the
+ // payload. An upstream 403 is not safe to replay because it may have been forwarded.
+ if (response.StatusCode is 404 or 405)
+ {
+ if (LeaveLocalRoute())
+ {
+ await SendDirectAsync(intakePath, sendAsync).ConfigureAwait(false);
+ }
+ else
+ {
+ Log.Warning("Feature Flags local EVP request failed with HTTP status code {StatusCode}", response.StatusCode);
+ }
+
+ return;
+ }
+
+ // Other responses may have come from upstream after the Agent accepted the payload.
+ // Never replay this batch, but leave the failed route for future Agentless batches.
+ LeaveLocalRoute();
+ Log.Warning("Feature Flags local EVP request failed with HTTP status code {StatusCode}", response.StatusCode);
+ }
+ catch (Exception ex) when (ClassifyNetworkFailure(ex) is NetworkFailure.DefinitivePreSend)
+ {
+ if (LeaveLocalRoute())
+ {
+ await SendDirectAsync(intakePath, sendAsync).ConfigureAwait(false);
+ }
+ else
+ {
+ Log.ErrorSkipTelemetry(ex, "Feature Flags local EVP request failed before the payload was sent");
+ }
+ }
+ catch (Exception ex) when (ClassifyNetworkFailure(ex) is NetworkFailure.Ambiguous)
+ {
+ // The local relay may have received this payload. Switch only future payloads so the
+ // current one can never be duplicated across the local and direct routes.
+ LeaveLocalRoute();
+
+ Log.ErrorSkipTelemetry(ex, "Feature Flags local EVP request failed ambiguously; the current event batch will not be replayed");
+ }
+ }
+
+ private bool LeaveLocalRoute()
+ {
+ if (_source != FeatureFlagsSource.Agentless)
+ {
+ return false;
+ }
+
+ if (_directRequestFactory is not null)
+ {
+ Interlocked.Exchange(ref _directIsSticky, 1);
+ return true;
+ }
+
+ var unavailableUntil = _utcNow().Add(_routeRecoveryCooldown).UtcTicks;
+ Interlocked.Exchange(ref _localUnavailableUntilUtcTicks, unavailableUntil);
+ return false;
+ }
+
+ private async Task SendDirectAsync(string intakePath, Func> sendAsync)
+ {
+ var directFactory = _directRequestFactory;
+ // The local send can outlive the bounded shutdown wait. Its continuation must not
+ // start a new fallback request after the transport has been disposed.
+ if (Volatile.Read(ref _disposed) != 0 || directFactory is null)
+ {
+ return;
+ }
+
+ var endpoint = directFactory.GetEndpoint(intakePath);
+ try
+ {
+ var request = directFactory.Create(endpoint);
+ using var response = await sendAsync(request).ConfigureAwait(false);
+ if (response.StatusCode is < 200 or >= 300)
+ {
+ const string Message = "Feature Flags direct EVP request to {Endpoint} failed with HTTP status code {StatusCode} after 1 attempt; the batch will not be replayed. See https://docs.datadoghq.com/feature_flags/";
+ if (response.StatusCode == 400)
+ {
+ _log.Error(Message, intakePath, response.StatusCode);
+ }
+ else
+ {
+ _log.ErrorSkipTelemetry(Message, intakePath, response.StatusCode);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ // Direct intake is terminal: never loop a failed direct request back through the Agent.
+ Log.ErrorSkipTelemetry(ex, "Feature Flags direct EVP request failed");
+ }
+ }
+
+ // Snapshot the factory together with its capabilities: a send can finish on the old Agent,
+ // but must never use its capabilities with the replacement Agent's factory.
+ private sealed class LocalEndpoint(IApiRequestFactory factory, ExporterSettings? exporterSettings)
+ {
+ private string? _proxyEndpoint;
+
+ public IApiRequestFactory Factory { get; } = factory;
+
+ public ExporterSettings? ExporterSettings { get; } = exporterSettings;
+
+ public TaskCompletionSource Discovery { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public string? ProxyEndpoint
+ {
+ get => Volatile.Read(ref _proxyEndpoint);
+ set => Volatile.Write(ref _proxyEndpoint, value);
+ }
}
}
diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsModule.cs b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsModule.cs
index 538657be7beb..2343454761af 100644
--- a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsModule.cs
+++ b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsModule.cs
@@ -9,6 +9,7 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
+using Datadog.Trace.Agent.DiscoveryService;
using Datadog.Trace.Configuration;
using Datadog.Trace.FeatureFlags.Agentless;
using Datadog.Trace.FeatureFlags.Evp;
@@ -46,6 +47,7 @@ internal sealed class FeatureFlagsModule : IDisposable
// ExposureApi reads only settings.Manager but takes TracerSettings. Held so the API can be
// built on the first exposure instead of at startup.
private readonly TracerSettings _tracerSettings;
+ private readonly IDiscoveryService _discoveryService;
// A factory rather than the static Create, so a test can supply a source that records what the
// module does with it: whether it is started before activation, and whether it is disposed.
@@ -75,7 +77,8 @@ internal sealed class FeatureFlagsModule : IDisposable
internal FeatureFlagsModule(
TracerSettings settings,
IRcmSubscriptionManager rcmSubscriptionManager,
- Func? agentlessSourceFactory = null)
+ Func? agentlessSourceFactory = null,
+ IDiscoveryService? discoveryService = null)
{
_settings = settings.FeatureFlags;
_settingsManager = settings.Manager;
@@ -85,6 +88,7 @@ internal FeatureFlagsModule(
_agentlessSourceFactory = agentlessSourceFactory
?? (static module => AgentlessConfigurationSource.Create(module._settings, module._settingsManager, module.ApplyConfiguration));
_rcmSubscriptionManager = rcmSubscriptionManager;
+ _discoveryService = discoveryService ?? NullDiscoveryService.Instance;
Log.Debug("FeatureFlagsModule ENABLED with source {Source}", _settings.Source);
}
@@ -107,14 +111,15 @@ internal FeatureFlagsModule(
public static FeatureFlagsModule? Create(
TracerSettings settings,
IRcmSubscriptionManager rcmSubscriptionManager,
- Func? agentlessSourceFactory = null)
+ Func? agentlessSourceFactory = null,
+ IDiscoveryService? discoveryService = null)
{
if (!settings.FeatureFlags.Enabled)
{
return null;
}
- var module = new FeatureFlagsModule(settings, rcmSubscriptionManager, agentlessSourceFactory);
+ var module = new FeatureFlagsModule(settings, rcmSubscriptionManager, agentlessSourceFactory, discoveryService);
// Subscribing from here rather than the constructor, so the callback can only ever reach
// a fully constructed module.
@@ -508,7 +513,7 @@ private void ReportExposure(in ExposureEvent exposure)
if (exposureApi is null)
{
// Keep the HTTP client and its settings subscription lazy with the first exposure.
- _evpTransport = new FeatureFlagsEvpTransport(_tracerSettings);
+ _evpTransport = new FeatureFlagsEvpTransport(_tracerSettings, _discoveryService);
exposureApi = new ExposureApi(_tracerSettings, _evpTransport);
Volatile.Write(ref _exposureApi, exposureApi);
}
diff --git a/tracer/src/Datadog.Trace/HttpOverStreams/DatadogHttpClient.cs b/tracer/src/Datadog.Trace/HttpOverStreams/DatadogHttpClient.cs
index c6fac8b8c2b8..0c24ee070227 100644
--- a/tracer/src/Datadog.Trace/HttpOverStreams/DatadogHttpClient.cs
+++ b/tracer/src/Datadog.Trace/HttpOverStreams/DatadogHttpClient.cs
@@ -1,4 +1,4 @@
-//
+//
// 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.
//
@@ -89,7 +89,7 @@ async Task GoNextChar()
var bytesRead = await responseStream.ReadAsync(chArray, offset: 0, count: 1).ConfigureAwait(false);
if (bytesRead == 0)
{
- ThrowHelper.ThrowInvalidOperationException($"Unexpected end of stream at position {streamPosition}");
+ throw new EndOfStreamException($"Unexpected end of stream at position {streamPosition}");
}
currentChar = Encoding.ASCII.GetChars(chArray)[0];
@@ -112,7 +112,7 @@ async Task SkipUntil(int requiredStreamPosition)
lastBytesRead = await responseStream.ReadAsync(chArray, offset: 0, count: bytesToRead).ConfigureAwait(false);
if (lastBytesRead == 0)
{
- ThrowHelper.ThrowInvalidOperationException($"Unexpected end of stream at position {streamPosition}");
+ throw new EndOfStreamException($"Unexpected end of stream at position {streamPosition}");
}
bytesRemaining -= lastBytesRead;
diff --git a/tracer/src/Datadog.Trace/TracerManagerFactory.cs b/tracer/src/Datadog.Trace/TracerManagerFactory.cs
index f984352592cc..d634f00002e5 100644
--- a/tracer/src/Datadog.Trace/TracerManagerFactory.cs
+++ b/tracer/src/Datadog.Trace/TracerManagerFactory.cs
@@ -191,7 +191,7 @@ internal TracerManager CreateTracerManager(
}
}
- featureFlags = FeatureFlagsModule.Create(settings, RcmSubscriptionManager.Instance);
+ featureFlags = FeatureFlagsModule.Create(settings, RcmSubscriptionManager.Instance, discoveryService: discoveryService);
return CreateTracerManagerFrom(
settings,
diff --git a/tracer/test/Datadog.Trace.TestHelpers/TransportHelpers/TestRequestFactory.cs b/tracer/test/Datadog.Trace.TestHelpers/TransportHelpers/TestRequestFactory.cs
index 9b84a117f5fa..4276217b8aa4 100644
--- a/tracer/test/Datadog.Trace.TestHelpers/TransportHelpers/TestRequestFactory.cs
+++ b/tracer/test/Datadog.Trace.TestHelpers/TransportHelpers/TestRequestFactory.cs
@@ -1,4 +1,4 @@
-//
+//
// 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.
//
diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/AgentlessEndpointTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/AgentlessEndpointTests.cs
index 6e024bfe7456..7f1252252ff1 100644
--- a/tracer/test/Datadog.Trace.Tests/FeatureFlags/AgentlessEndpointTests.cs
+++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/AgentlessEndpointTests.cs
@@ -19,6 +19,7 @@ public class AgentlessEndpointTests
[Theory]
[InlineData("datadoghq.com", "https://ufc-server.ff-cdn.datadoghq.com" + DefaultPath)]
[InlineData("DATADOGHQ.COM", "https://ufc-server.ff-cdn.datadoghq.com" + DefaultPath)] // site is lowercased
+ [InlineData(" datadoghq.com ", "https://ufc-server.ff-cdn.datadoghq.com" + DefaultPath)] // surrounding whitespace is trimmed
[InlineData("datad0g.com", "https://ufc-server.ff-cdn.datad0g.com" + DefaultPath)] // staging
[InlineData("ddog-gov.com", "https://ufc-server.ff-cdn.ddog-gov.com" + DefaultPath)] // govcloud
public void DerivesManagedEndpointFromSite(string site, string expected)
@@ -140,6 +141,11 @@ public void RejectsWhitespaceOnlySiteWithoutBaseUrl()
[InlineData("datadoghq.com/../evil")] // a path escapes the host
[InlineData("datadoghq.com?x=1")] // a query escapes the host
[InlineData("datadoghq.com#f")] // a fragment escapes the host
+ [InlineData("datadoghq.com\\attacker.example")] // a backslash can be normalized as a URL separator
+ [InlineData("dátadoghq.com")] // managed endpoints do not implicitly convert Unicode to IDN
+ [InlineData("-datadoghq.com")]
+ [InlineData("datadoghq.com-")]
+ [InlineData("datadoghq..com")]
public void RejectsMalformedSiteWithoutThrowing(string site)
{
AgentlessEndpoint.TryCreate(site, baseUrl: null, out var endpoint, out var error)
diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/ExposureApiTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/ExposureApiTests.cs
index 12f79f8db2ba..5fe8f3d10b70 100644
--- a/tracer/test/Datadog.Trace.Tests/FeatureFlags/ExposureApiTests.cs
+++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/ExposureApiTests.cs
@@ -13,11 +13,13 @@
using Datadog.Trace.Agent;
using Datadog.Trace.Agent.Transports;
using Datadog.Trace.Configuration;
+using Datadog.Trace.FeatureFlags;
using Datadog.Trace.FeatureFlags.Evp;
using Datadog.Trace.FeatureFlags.Exposure;
using Datadog.Trace.FeatureFlags.Exposure.Model;
using Datadog.Trace.HttpOverStreams;
using Datadog.Trace.TestHelpers.TransportHelpers;
+using Datadog.Trace.Tests.Agent;
using Datadog.Trace.Vendors.Newtonsoft.Json;
using Datadog.Trace.Vendors.Newtonsoft.Json.Linq;
using FluentAssertions;
@@ -52,7 +54,7 @@ public async Task DisposeFlushesEventsQueuedWhileAnEarlierBatchIsSending()
local.RequestsSent.Should().HaveCount(2, "the second exposure is sent by the shutdown flush");
var finalRequest = local.RequestsSent[1].Should().BeOfType().Subject;
- finalRequest.Endpoint.AbsolutePath.Should().Be("/evp_proxy/v2/api/v2/exposures");
+ finalRequest.Endpoint.AbsolutePath.Should().Be("/evp_proxy/v4/api/v2/exposures");
finalRequest.Compression.Should().Be(MultipartCompression.GZip);
var payload = JObject.Parse(finalRequest.PayloadJson);
payload["context"]!["service"]!.Value().Should().NotBeNullOrEmpty();
@@ -98,7 +100,12 @@ public async Task SendExposureAfterDisposeIsIgnored()
}
private static FeatureFlagsEvpTransport CreateLocalTransport(TestRequestFactory local)
- => new(local);
+ => new(
+ FeatureFlagsSource.Agentless,
+ local,
+ directRequestFactory: null,
+ discoveryService: new DiscoveryServiceMock(),
+ initialLocalProxyEndpoint: FeatureFlagsEvpTransport.EventPlatformProxyV4);
private static ExposureEvent CreateExposure(string flag)
=> new(
diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FeatureFlagsEvpTransportTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FeatureFlagsEvpTransportTests.cs
new file mode 100644
index 000000000000..7bf229d5a5ba
--- /dev/null
+++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FeatureFlagsEvpTransportTests.cs
@@ -0,0 +1,1009 @@
+//
+// 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.Collections.Specialized;
+using System.IO;
+using System.IO.Pipes;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Net.Sockets;
+using System.Reflection;
+using System.Text;
+using System.Threading.Tasks;
+using Datadog.Trace.Agent;
+using Datadog.Trace.Agent.DiscoveryService;
+using Datadog.Trace.Agent.Transports;
+using Datadog.Trace.ClrProfiler.AutoInstrumentation.ManualInstrumentation;
+using Datadog.Trace.Configuration;
+using Datadog.Trace.Configuration.ConfigurationSources;
+using Datadog.Trace.Configuration.Telemetry;
+using Datadog.Trace.FeatureFlags;
+using Datadog.Trace.FeatureFlags.Evp;
+using Datadog.Trace.HttpOverStreams;
+using Datadog.Trace.Logging;
+using Datadog.Trace.PlatformHelpers;
+using Datadog.Trace.Telemetry;
+using Datadog.Trace.TestHelpers;
+using Datadog.Trace.TestHelpers.TransportHelpers;
+using Datadog.Trace.Tests.Agent;
+using Datadog.Trace.Vendors.Newtonsoft.Json;
+using FluentAssertions;
+using Moq;
+using Xunit;
+
+namespace Datadog.Trace.Tests.FeatureFlags;
+
+[Collection(nameof(WebRequestCollection))]
+public class FeatureFlagsEvpTransportTests
+{
+ private static readonly JsonSerializerSettings SerializerSettings = new();
+
+ public static IEnumerable