diff --git a/tracer/src/Datadog.Trace/Configuration/supported-configurations.yaml b/tracer/src/Datadog.Trace/Configuration/supported-configurations.yaml index 3342c5173299..cef99aae2bcf 100644 --- a/tracer/src/Datadog.Trace/Configuration/supported-configurations.yaml +++ b/tracer/src/Datadog.Trace/Configuration/supported-configurations.yaml @@ -3147,6 +3147,78 @@ supportedConfigurations: documentation: |- Enables Feature Flags Provider (Experimental). Default value is false (disabled). + DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS: + - implementation: B + scope: managed + type: int + default: '10000' + product: FeatureFlags + const_name: FlaggingProviderInitializationTimeoutMs + documentation: |- + Configuration key for how long, in milliseconds, provider initialization waits for the first + flag configuration to arrive before returning. + Default value is 10000 (10 seconds). + Initialization does not fail when the timeout expires: the provider stays not-ready, evaluations + return the caller's default value, and the provider becomes ready when configuration arrives. + DD_FEATURE_FLAGS_ENABLED: + - implementation: A + scope: managed + type: boolean + default: 'true' + product: FeatureFlags + const_name: FeatureFlagsEnabled + documentation: |- + Configuration key to enable or disable Feature Flags. + Default value is true (enabled). + Feature Flags only contact Datadog once application code initializes the provider, so enabling + this alone does not start requesting flag configuration. + This supersedes . + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE: + - implementation: A + scope: managed + type: string + default: agentless + product: FeatureFlags + const_name: FeatureFlagsConfigurationSource + documentation: |- + Configuration key for selecting where flag configuration is loaded from. + Supported values are agentless (direct HTTP delivery, the default) and + remote_config (delivery through the Datadog Agent's Remote Configuration). + Any other value disables Feature Flags, which fails closed and contacts nothing. + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: + - implementation: A + scope: managed + type: string + default: null + product: FeatureFlags + const_name: FeatureFlagsConfigurationSourceAgentlessBaseUrl + documentation: |- + Configuration key for overriding the endpoint used by the agentless configuration source. + When the URL has no path, or a path of /, the standard rules-based server path is appended; + any other path is used verbatim as the exact endpoint. + If unset, the endpoint is derived from . + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS: + - implementation: A + scope: managed + type: int + default: '30' + product: FeatureFlags + const_name: FeatureFlagsConfigurationSourceAgentlessPollIntervalSeconds + documentation: |- + Configuration key for how often, in seconds, the agentless configuration source polls for + flag configuration. + Default value is 30. Values outside (0, 3600] are rejected and the default is used. + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS: + - implementation: A + scope: managed + type: int + default: '5' + product: FeatureFlags + const_name: FeatureFlagsConfigurationSourceAgentlessRequestTimeoutSeconds + documentation: |- + Configuration key for the request timeout, in seconds, used by the agentless configuration + source. + Default value is 5. Non-positive values are rejected and the default is used. DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED: - implementation: A scope: managed diff --git a/tracer/src/Datadog.Trace/FeatureFlags/Agentless/AgentlessEndpoint.cs b/tracer/src/Datadog.Trace/FeatureFlags/Agentless/AgentlessEndpoint.cs new file mode 100644 index 000000000000..e0f60f1c4e9d --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/Agentless/AgentlessEndpoint.cs @@ -0,0 +1,141 @@ +// +// 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 Datadog.Trace.Util; + +namespace Datadog.Trace.FeatureFlags.Agentless; + +/// +/// The agentless endpoint, derived from the Datadog site or a custom base URL. +/// +internal readonly struct AgentlessEndpoint +{ + /// + /// Canonical rules-based server path, appended to the managed CDN host and to custom base + /// URLs that only supply an origin. + /// + internal const string DefaultPath = "/api/v2/feature-flagging/config/rules-based/server"; + + /// + /// The prefix prepended to the site to form the managed CDN host. + /// + internal const string ManagedHostPrefix = "ufc-server.ff-cdn."; + + private AgentlessEndpoint(Uri uri, bool isManaged) + { + Uri = uri; + IsManaged = isManaged; + } + + /// + /// Gets the endpoint URI. + /// + public Uri Uri { get; } + + /// + /// Gets a value indicating whether this is the endpoint derived from the site. The API key is + /// only sent there: a custom endpoint reports its own authentication failure rather than + /// having the credential guessed onto it. + /// + public bool IsManaged { get; } + + /// + /// Builds the endpoint. Without a custom the managed Datadog CDN + /// endpoint is derived from the (lowercased) site, so staging and government sites resolve + /// with no allowlist, and dd_env is added only when an environment is configured. + /// A custom base URL that is an origin receives the canonical path; one that carries a path + /// is used verbatim. + /// + /// The Datadog site, for example datadoghq.com. + /// The configured environment, or null. + /// The configured endpoint override, or null. + /// The resulting endpoint. + /// Why the configured base URL was rejected. Never contains the URL, which may carry credentials. + /// true when an endpoint could be built. + public static bool TryCreate(string? site, string? env, string? baseUrl, out AgentlessEndpoint endpoint, out string? error) + { + endpoint = default; + error = null; + + var configured = baseUrl?.Trim(); + if (StringUtil.IsNullOrEmpty(configured)) + { + var trimmedSite = site?.Trim(); + if (StringUtil.IsNullOrEmpty(trimmedSite)) + { + error = "No Datadog site is configured"; + return false; + } + + var managedHost = ManagedHostPrefix + trimmedSite!.ToLowerInvariant(); + if (managedHost.Contains("://") || HasWhitespace(managedHost)) + { + error = "The configured Datadog site is not valid"; + return false; + } + + if (!Uri.TryCreate($"https://{managedHost}{DefaultPath}", UriKind.Absolute, out var managedUri)) + { + error = "The configured Datadog site is not valid"; + return false; + } + + if (!StringUtil.IsNullOrEmpty(env)) + { + managedUri = new UriBuilder(managedUri) { Query = "dd_env=" + Uri.EscapeDataString(env!) }.Uri; + } + + endpoint = new AgentlessEndpoint(managedUri, isManaged: true); + return true; + } + + // A URL with internal whitespace is malformed, and Uri parsing is lenient enough to accept it. + foreach (var character in configured!) + { + if (char.IsWhiteSpace(character)) + { + error = "The configured Feature Flags agentless URL is not a valid URL"; + return false; + } + } + + if (!Uri.TryCreate(configured, UriKind.Absolute, out var custom) || StringUtil.IsNullOrEmpty(custom.Host)) + { + error = "The configured Feature Flags agentless URL is not a valid absolute URL"; + return false; + } + + // http is accepted for a custom endpoint only: pointing at one is an operator decision. + if (custom.Scheme != Uri.UriSchemeHttps && custom.Scheme != Uri.UriSchemeHttp) + { + error = "The configured Feature Flags agentless URL must use HTTP or HTTPS"; + return false; + } + + if (custom.AbsolutePath is "" or "/") + { + custom = new UriBuilder(custom) { Path = DefaultPath }.Uri; + } + + endpoint = new AgentlessEndpoint(custom, isManaged: false); + return true; + } + + private static bool HasWhitespace(string value) + { + foreach (var c in value) + { + if (char.IsWhiteSpace(c)) + { + return true; + } + } + + return false; + } +} diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsSettings.cs b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsSettings.cs new file mode 100644 index 000000000000..e25c97fd0dfa --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsSettings.cs @@ -0,0 +1,229 @@ +// +// 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 Datadog.Trace.Configuration; +using Datadog.Trace.Configuration.Telemetry; +using Datadog.Trace.Logging; +using Datadog.Trace.Telemetry; +using Datadog.Trace.Util; + +namespace Datadog.Trace.FeatureFlags; + +/// +/// Feature Flags configuration: which delivery source is selected, and how the agentless +/// source is operated. +/// +internal sealed class FeatureFlagsSettings +{ + internal const string AgentlessSourceName = "agentless"; + internal const string RemoteConfigSourceName = "remote_config"; + internal const string OfflineSourceName = "offline"; + + internal const string DefaultSite = "datadoghq.com"; + + internal const int DefaultPollIntervalSeconds = 30; + internal const int DefaultRequestTimeoutSeconds = 5; + internal const int DefaultInitializationTimeoutMs = 10_000; + + // An interval above this is indistinguishable from "never poll" and is more likely a + // misconfiguration (for example milliseconds passed as seconds) than an intent. + private const int MaxPollIntervalSeconds = 3600; + + private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(typeof(FeatureFlagsSettings)); + + public FeatureFlagsSettings(IConfigurationSource? source, IConfigurationTelemetry telemetry) + { + source ??= NullConfigurationSource.Instance; + var config = new ConfigurationBuilder(source, telemetry); + + // Read as nullable: the precedence rules distinguish "explicitly provided" from "left unset", + // so a default value here would erase the difference the legacy key depends on. + var enabled = config.WithKeys(ConfigurationKeys.FeatureFlags.FeatureFlagsEnabled).AsBool(); +#pragma warning disable 618 // superseded, but still honoured so existing adopters keep their source + var legacyEnabled = config.WithKeys(ConfigurationKeys.FeatureFlags.FlaggingProviderEnabled).AsBool(); +#pragma warning restore 618 + var configuredSource = config.WithKeys(ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSource).AsString(); + + if (legacyEnabled is not null) + { +#pragma warning disable 618 + Log.Warning( + "{LegacyKey} is deprecated. Use {EnabledKey} and {SourceKey} instead.", + ConfigurationKeys.FeatureFlags.FlaggingProviderEnabled, + ConfigurationKeys.FeatureFlags.FeatureFlagsEnabled, + ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSource); +#pragma warning restore 618 + } + + Source = ResolveSource(enabled, configuredSource, legacyEnabled); + + var agentlessBaseUrl = config + .WithKeys(ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSourceAgentlessBaseUrl) + .AsRedactedString(); + AgentlessBaseUrl = !StringUtil.IsNullOrEmpty(agentlessBaseUrl?.Trim()) ? agentlessBaseUrl : null; + + PollInterval = TimeSpan.FromSeconds( + InRangeOrDefault( + config.WithKeys(ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSourceAgentlessPollIntervalSeconds).AsInt32(), + ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSourceAgentlessPollIntervalSeconds, + DefaultPollIntervalSeconds, + MaxPollIntervalSeconds)); + + RequestTimeout = TimeSpan.FromSeconds( + InRangeOrDefault( + config.WithKeys(ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSourceAgentlessRequestTimeoutSeconds).AsInt32(), + ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSourceAgentlessRequestTimeoutSeconds, + DefaultRequestTimeoutSeconds, + maximumSeconds: null)); + + Site = config + .WithKeys(ConfigurationKeys.Site) + .AsString(DefaultSite, site => !StringUtil.IsNullOrEmpty(site?.Trim())); + + Env = config.WithKeys(ConfigurationKeys.Environment).AsString(); + + ApiKey = config.WithKeys(ConfigurationKeys.ApiKey).AsRedactedString(); + + var initializationTimeoutMs = config + .WithKeys(ConfigurationKeys.FeatureFlags.FlaggingProviderInitializationTimeoutMs) + .AsInt32(DefaultInitializationTimeoutMs, timeout => timeout > 0) + .Value; + InitializationTimeout = TimeSpan.FromMilliseconds(initializationTimeoutMs); + } + + /// + /// Gets the resolved delivery source. means nothing is contacted. + /// + public FeatureFlagsSource Source { get; } + + /// + /// Gets a value indicating whether Feature Flags are enabled at all. + /// + public bool Enabled => Source != FeatureFlagsSource.Disabled; + + /// + /// Gets the configured override for the agentless endpoint, or null to derive it from the site. + /// + public string? AgentlessBaseUrl { get; } + + /// + /// Gets the Datadog site the managed agentless endpoint is derived from. + /// + public string Site { get; } + + /// + /// Gets the configured environment, sent to the agentless endpoint as dd_env. + /// + public string? Env { get; } + + /// + /// Gets the API key, required by the managed agentless endpoint. + /// + public string? ApiKey { get; } + + /// + /// Gets how often the agentless source polls for configuration. + /// + public TimeSpan PollInterval { get; } + + /// + /// Gets the per-request timeout used by the agentless source. + /// + public TimeSpan RequestTimeout { get; } + + /// + /// Gets how long provider initialization waits for the first configuration. + /// + public TimeSpan InitializationTimeout { get; } + + public static FeatureFlagsSettings FromDefaultSource() + => new(GlobalConfigurationSource.Instance, TelemetryFactory.Config); + + /// + /// Resolves the delivery source. Shared across tracers, so the ordering is deliberate: + /// the stable kill switch wins over everything, an explicit source wins over the legacy key + /// (and fails closed when unrecognised), the legacy key grandfathers existing adopters onto + /// Remote Configuration, and everything else defaults to agentless. + /// + internal static FeatureFlagsSource ResolveSource(bool? enabled, string? configuredSource, bool? legacyEnabled) + { + var normalizedSource = NormalizeSource(configuredSource); + + if (enabled == false) + { + return FeatureFlagsSource.Disabled; + } + + if (normalizedSource is not null) + { + switch (normalizedSource) + { + case AgentlessSourceName: + return FeatureFlagsSource.Agentless; + case RemoteConfigSourceName: + return FeatureFlagsSource.RemoteConfig; + case OfflineSourceName: + // Reserved fail-closed sentinel: the provider is intentionally off, so no warning. + return FeatureFlagsSource.Disabled; + default: + Log.Warning( + "Unsupported {SourceKey} value '{Source}'. Feature Flags are disabled.", + ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSource, + normalizedSource); + return FeatureFlagsSource.Disabled; + } + } + + // The legacy key only grandfathers adopters who have not migrated: an explicit new-key + // value (true or false) takes precedence, so the legacy key is consulted only when the + // new key was left unset. + if (enabled is null && legacyEnabled is not null) + { + return legacyEnabled.Value ? FeatureFlagsSource.RemoteConfig : FeatureFlagsSource.Disabled; + } + + return FeatureFlagsSource.Agentless; + } + + /// + /// An empty or whitespace-only source is semantically unset, not an unrecognised value. + /// + private static string? NormalizeSource(string? configuredSource) + { + if (configuredSource is null) + { + return null; + } + + var normalized = configuredSource.Trim().ToLowerInvariant(); + return normalized.Length == 0 ? null : normalized; + } + + internal static int InRangeOrDefault(int? configured, string key, int defaultSeconds, int? maximumSeconds) + { + if (configured is null) + { + return defaultSeconds; + } + + var value = configured.Value; + if (value <= 0 || (maximumSeconds is { } maximum && value > maximum)) + { + // A non-positive interval would turn polling into a tight loop against the endpoint, + // so an out-of-range value is rejected rather than honoured. + Log.Warning( + "Invalid value {Key}={Value}. Using {Default} seconds instead.", + key, + value, + defaultSeconds); + return defaultSeconds; + } + + return value; + } +} diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsSource.cs b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsSource.cs new file mode 100644 index 000000000000..dd50047ede42 --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsSource.cs @@ -0,0 +1,29 @@ +// +// 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 + +namespace Datadog.Trace.FeatureFlags; + +/// +/// Where flag configuration is loaded from. +/// +internal enum FeatureFlagsSource +{ + /// + /// Feature Flags are disabled: no configuration is loaded, and neither delivery path is contacted. + /// + Disabled, + + /// + /// Configuration is fetched over HTTP, without the Datadog Agent. + /// + Agentless, + + /// + /// Configuration is delivered through the Datadog Agent's Remote Configuration. + /// + RemoteConfig, +} diff --git a/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs b/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs index e3c49c08f659..c0bc6ef9a199 100644 --- a/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs +++ b/tracer/src/Datadog.Trace/Generated/net461/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs @@ -23,12 +23,60 @@ internal static class FeatureFlags /// public const string FlaggingProviderEnabled = "DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED"; + /// + /// Configuration key for how long, in milliseconds, provider initialization waits for the first + /// flag configuration to arrive before returning. + /// Default value is 10000 (10 seconds). + /// Initialization does not fail when the timeout expires: the provider stays not-ready, evaluations + /// return the caller's default value, and the provider becomes ready when configuration arrives. + /// + public const string FlaggingProviderInitializationTimeoutMs = "DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS"; + /// /// Enables APM span enrichment with feature-flag evaluation metadata (Experimental). /// Default value is false (disabled). /// public const string SpanEnrichmentEnabled = "DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED"; + /// + /// Configuration key for selecting where flag configuration is loaded from. + /// Supported values are agentless (direct HTTP delivery, the default) and + /// remote_config (delivery through the Datadog Agent's Remote Configuration). + /// Any other value disables Feature Flags, which fails closed and contacts nothing. + /// + public const string FeatureFlagsConfigurationSource = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE"; + + /// + /// Configuration key for overriding the endpoint used by the agentless configuration source. + /// When the URL has no path, or a path of /, the standard rules-based server path is appended; + /// any other path is used verbatim as the exact endpoint. + /// If unset, the endpoint is derived from . + /// + public const string FeatureFlagsConfigurationSourceAgentlessBaseUrl = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL"; + + /// + /// Configuration key for how often, in seconds, the agentless configuration source polls for + /// flag configuration. + /// Default value is 30. Values outside (0, 3600] are rejected and the default is used. + /// + public const string FeatureFlagsConfigurationSourceAgentlessPollIntervalSeconds = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS"; + + /// + /// Configuration key for the request timeout, in seconds, used by the agentless configuration + /// source. + /// Default value is 5. Non-positive values are rejected and the default is used. + /// + public const string FeatureFlagsConfigurationSourceAgentlessRequestTimeoutSeconds = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS"; + + /// + /// Configuration key to enable or disable Feature Flags. + /// Default value is true (enabled). + /// Feature Flags only contact Datadog once application code initializes the provider, so enabling + /// this alone does not start requesting flag configuration. + /// This supersedes . + /// + public const string FeatureFlagsEnabled = "DD_FEATURE_FLAGS_ENABLED"; + /// /// Enables support for collecting and exporting logs generated by the the OpenTelemetry Logs API. /// This feature is available starting with .NET 3.1 when using Microsoft.Extensions.Logging diff --git a/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs b/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs index e3c49c08f659..c0bc6ef9a199 100644 --- a/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs +++ b/tracer/src/Datadog.Trace/Generated/net6.0/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs @@ -23,12 +23,60 @@ internal static class FeatureFlags /// public const string FlaggingProviderEnabled = "DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED"; + /// + /// Configuration key for how long, in milliseconds, provider initialization waits for the first + /// flag configuration to arrive before returning. + /// Default value is 10000 (10 seconds). + /// Initialization does not fail when the timeout expires: the provider stays not-ready, evaluations + /// return the caller's default value, and the provider becomes ready when configuration arrives. + /// + public const string FlaggingProviderInitializationTimeoutMs = "DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS"; + /// /// Enables APM span enrichment with feature-flag evaluation metadata (Experimental). /// Default value is false (disabled). /// public const string SpanEnrichmentEnabled = "DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED"; + /// + /// Configuration key for selecting where flag configuration is loaded from. + /// Supported values are agentless (direct HTTP delivery, the default) and + /// remote_config (delivery through the Datadog Agent's Remote Configuration). + /// Any other value disables Feature Flags, which fails closed and contacts nothing. + /// + public const string FeatureFlagsConfigurationSource = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE"; + + /// + /// Configuration key for overriding the endpoint used by the agentless configuration source. + /// When the URL has no path, or a path of /, the standard rules-based server path is appended; + /// any other path is used verbatim as the exact endpoint. + /// If unset, the endpoint is derived from . + /// + public const string FeatureFlagsConfigurationSourceAgentlessBaseUrl = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL"; + + /// + /// Configuration key for how often, in seconds, the agentless configuration source polls for + /// flag configuration. + /// Default value is 30. Values outside (0, 3600] are rejected and the default is used. + /// + public const string FeatureFlagsConfigurationSourceAgentlessPollIntervalSeconds = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS"; + + /// + /// Configuration key for the request timeout, in seconds, used by the agentless configuration + /// source. + /// Default value is 5. Non-positive values are rejected and the default is used. + /// + public const string FeatureFlagsConfigurationSourceAgentlessRequestTimeoutSeconds = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS"; + + /// + /// Configuration key to enable or disable Feature Flags. + /// Default value is true (enabled). + /// Feature Flags only contact Datadog once application code initializes the provider, so enabling + /// this alone does not start requesting flag configuration. + /// This supersedes . + /// + public const string FeatureFlagsEnabled = "DD_FEATURE_FLAGS_ENABLED"; + /// /// Enables support for collecting and exporting logs generated by the the OpenTelemetry Logs API. /// This feature is available starting with .NET 3.1 when using Microsoft.Extensions.Logging diff --git a/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs b/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs index e3c49c08f659..c0bc6ef9a199 100644 --- a/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs +++ b/tracer/src/Datadog.Trace/Generated/netcoreapp3.1/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs @@ -23,12 +23,60 @@ internal static class FeatureFlags /// public const string FlaggingProviderEnabled = "DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED"; + /// + /// Configuration key for how long, in milliseconds, provider initialization waits for the first + /// flag configuration to arrive before returning. + /// Default value is 10000 (10 seconds). + /// Initialization does not fail when the timeout expires: the provider stays not-ready, evaluations + /// return the caller's default value, and the provider becomes ready when configuration arrives. + /// + public const string FlaggingProviderInitializationTimeoutMs = "DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS"; + /// /// Enables APM span enrichment with feature-flag evaluation metadata (Experimental). /// Default value is false (disabled). /// public const string SpanEnrichmentEnabled = "DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED"; + /// + /// Configuration key for selecting where flag configuration is loaded from. + /// Supported values are agentless (direct HTTP delivery, the default) and + /// remote_config (delivery through the Datadog Agent's Remote Configuration). + /// Any other value disables Feature Flags, which fails closed and contacts nothing. + /// + public const string FeatureFlagsConfigurationSource = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE"; + + /// + /// Configuration key for overriding the endpoint used by the agentless configuration source. + /// When the URL has no path, or a path of /, the standard rules-based server path is appended; + /// any other path is used verbatim as the exact endpoint. + /// If unset, the endpoint is derived from . + /// + public const string FeatureFlagsConfigurationSourceAgentlessBaseUrl = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL"; + + /// + /// Configuration key for how often, in seconds, the agentless configuration source polls for + /// flag configuration. + /// Default value is 30. Values outside (0, 3600] are rejected and the default is used. + /// + public const string FeatureFlagsConfigurationSourceAgentlessPollIntervalSeconds = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS"; + + /// + /// Configuration key for the request timeout, in seconds, used by the agentless configuration + /// source. + /// Default value is 5. Non-positive values are rejected and the default is used. + /// + public const string FeatureFlagsConfigurationSourceAgentlessRequestTimeoutSeconds = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS"; + + /// + /// Configuration key to enable or disable Feature Flags. + /// Default value is true (enabled). + /// Feature Flags only contact Datadog once application code initializes the provider, so enabling + /// this alone does not start requesting flag configuration. + /// This supersedes . + /// + public const string FeatureFlagsEnabled = "DD_FEATURE_FLAGS_ENABLED"; + /// /// Enables support for collecting and exporting logs generated by the the OpenTelemetry Logs API. /// This feature is available starting with .NET 3.1 when using Microsoft.Extensions.Logging diff --git a/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs b/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs index e3c49c08f659..c0bc6ef9a199 100644 --- a/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs +++ b/tracer/src/Datadog.Trace/Generated/netstandard2.0/Datadog.Trace.SourceGenerators/ConfigurationKeysGenerator/ConfigurationKeys.FeatureFlags.g.cs @@ -23,12 +23,60 @@ internal static class FeatureFlags /// public const string FlaggingProviderEnabled = "DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED"; + /// + /// Configuration key for how long, in milliseconds, provider initialization waits for the first + /// flag configuration to arrive before returning. + /// Default value is 10000 (10 seconds). + /// Initialization does not fail when the timeout expires: the provider stays not-ready, evaluations + /// return the caller's default value, and the provider becomes ready when configuration arrives. + /// + public const string FlaggingProviderInitializationTimeoutMs = "DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS"; + /// /// Enables APM span enrichment with feature-flag evaluation metadata (Experimental). /// Default value is false (disabled). /// public const string SpanEnrichmentEnabled = "DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED"; + /// + /// Configuration key for selecting where flag configuration is loaded from. + /// Supported values are agentless (direct HTTP delivery, the default) and + /// remote_config (delivery through the Datadog Agent's Remote Configuration). + /// Any other value disables Feature Flags, which fails closed and contacts nothing. + /// + public const string FeatureFlagsConfigurationSource = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE"; + + /// + /// Configuration key for overriding the endpoint used by the agentless configuration source. + /// When the URL has no path, or a path of /, the standard rules-based server path is appended; + /// any other path is used verbatim as the exact endpoint. + /// If unset, the endpoint is derived from . + /// + public const string FeatureFlagsConfigurationSourceAgentlessBaseUrl = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL"; + + /// + /// Configuration key for how often, in seconds, the agentless configuration source polls for + /// flag configuration. + /// Default value is 30. Values outside (0, 3600] are rejected and the default is used. + /// + public const string FeatureFlagsConfigurationSourceAgentlessPollIntervalSeconds = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS"; + + /// + /// Configuration key for the request timeout, in seconds, used by the agentless configuration + /// source. + /// Default value is 5. Non-positive values are rejected and the default is used. + /// + public const string FeatureFlagsConfigurationSourceAgentlessRequestTimeoutSeconds = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS"; + + /// + /// Configuration key to enable or disable Feature Flags. + /// Default value is true (enabled). + /// Feature Flags only contact Datadog once application code initializes the provider, so enabling + /// this alone does not start requesting flag configuration. + /// This supersedes . + /// + public const string FeatureFlagsEnabled = "DD_FEATURE_FLAGS_ENABLED"; + /// /// Enables support for collecting and exporting logs generated by the the OpenTelemetry Logs API. /// This feature is available starting with .NET 3.1 when using Microsoft.Extensions.Logging diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/AgentlessEndpointTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/AgentlessEndpointTests.cs new file mode 100644 index 000000000000..3a737c8937ff --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/AgentlessEndpointTests.cs @@ -0,0 +1,127 @@ +// +// 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 Datadog.Trace.FeatureFlags.Agentless; +using FluentAssertions; +using Xunit; + +namespace Datadog.Trace.Tests.FeatureFlags; + +public class AgentlessEndpointTests +{ + private const string DefaultPath = "/api/v2/feature-flagging/config/rules-based/server"; + + [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("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) + { + AgentlessEndpoint.TryCreate(site, env: null, baseUrl: null, out var endpoint, out var error) + .Should().BeTrue(); + error.Should().BeNull(); + endpoint.IsManaged.Should().BeTrue(); + endpoint.Uri.ToString().Should().Be(expected); + } + + [Fact] + public void AddsDdEnvWhenEnvIsConfigured() + { + AgentlessEndpoint.TryCreate("datadoghq.com", env: "production", baseUrl: null, out var endpoint, out _) + .Should().BeTrue(); + endpoint.Uri.Query.Should().Be("?dd_env=production"); + } + + [Fact] + public void DoesNotAddDdEnvWhenEnvIsNull() + { + AgentlessEndpoint.TryCreate("datadoghq.com", env: null, baseUrl: null, out var endpoint, out _) + .Should().BeTrue(); + endpoint.Uri.Query.Should().BeEmpty(); + } + + [Fact] + public void EscapesDdEnvValue() + { + AgentlessEndpoint.TryCreate("datadoghq.com", env: "my env&test", baseUrl: null, out var endpoint, out _) + .Should().BeTrue(); + endpoint.Uri.Query.Should().Be("?dd_env=my%20env%26test"); + } + + [Theory] + [InlineData("https://flags.example.com", "https://flags.example.com" + DefaultPath)] + [InlineData("https://flags.example.com/", "https://flags.example.com" + DefaultPath)] + [InlineData("https://flags.example.com/ufc", "https://flags.example.com/ufc")] + [InlineData("https://flags.example.com/ufc?custom=query", "https://flags.example.com/ufc?custom=query")] + public void CustomEndpointReceivesCanonicalPathForOriginOnly(string baseUrl, string expected) + { + AgentlessEndpoint.TryCreate("datadoghq.com", env: null, baseUrl: baseUrl, out var endpoint, out var error) + .Should().BeTrue(); + error.Should().BeNull(); + endpoint.IsManaged.Should().BeFalse(); + endpoint.Uri.ToString().Should().Be(expected); + } + + [Theory] + [InlineData("http://localhost:8080/ufc")] // http accepted for custom endpoints + public void CustomEndpointAcceptsHttp(string baseUrl) + { + AgentlessEndpoint.TryCreate("datadoghq.com", env: null, baseUrl: baseUrl, out var endpoint, out var error) + .Should().BeTrue(); + endpoint.IsManaged.Should().BeFalse(); + } + + [Theory] + [InlineData("ftp://flags.example.com", "The configured Feature Flags agentless URL must use HTTP or HTTPS")] + [InlineData("notaurl", "The configured Feature Flags agentless URL is not a valid absolute URL")] + [InlineData("https://flags.example.com bad", "The configured Feature Flags agentless URL is not a valid URL")] // internal whitespace + public void RejectsInvalidBaseUrl(string baseUrl, string expectedError) + { + AgentlessEndpoint.TryCreate("datadoghq.com", env: null, baseUrl: baseUrl, out var endpoint, out var error) + .Should().BeFalse(); + error.Should().Be(expectedError); + } + + [Fact] + public void RejectsEmptySiteWithoutBaseUrl() + { + AgentlessEndpoint.TryCreate(site: null, env: null, baseUrl: null, out var endpoint, out var error) + .Should().BeFalse(); + error.Should().Be("No Datadog site is configured"); + } + + [Fact] + public void RejectsWhitespaceOnlySiteWithoutBaseUrl() + { + AgentlessEndpoint.TryCreate(" ", env: null, baseUrl: null, out var endpoint, out var error) + .Should().BeFalse(); + error.Should().Be("No Datadog site is configured"); + } + + [Theory] + [InlineData("https://datadoghq.com")] // user accidentally includes the scheme + [InlineData("data dog hq.com")] // internal spaces + [InlineData("datadoghq.com:99999")] // invalid port + public void RejectsMalformedSiteWithoutThrowing(string site) + { + AgentlessEndpoint.TryCreate(site, env: null, baseUrl: null, out var endpoint, out var error) + .Should().BeFalse(); + error.Should().Be("The configured Datadog site is not valid"); + } + + [Fact] + public void ErrorNeverContainsUrl() + { + // A URL may carry credentials, so the error must never echo it. + AgentlessEndpoint.TryCreate("datadoghq.com", env: null, baseUrl: "https://user:pass@flags.example.com bad", out _, out var error) + .Should().BeFalse(); + error.Should().NotContain("user"); + error.Should().NotContain("pass"); + } +} diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FeatureFlagsSettingsTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FeatureFlagsSettingsTests.cs new file mode 100644 index 000000000000..ec8be148a134 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FeatureFlagsSettingsTests.cs @@ -0,0 +1,184 @@ +// +// 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.Specialized; +using Datadog.Trace.Configuration; +using Datadog.Trace.Configuration.Telemetry; +using Datadog.Trace.FeatureFlags; +using FluentAssertions; +using Xunit; + +namespace Datadog.Trace.Tests.FeatureFlags; + +public class FeatureFlagsSettingsTests +{ + // The source-selection contract is shared across tracers, so these cases mirror the + // system-tests parametric suite (tests/parametric/test_ffe/test_configuration_sources.py). + [Theory] + // Nothing configured: agentless is the default. + [InlineData(null, null, null, FeatureFlagsSource.Agentless)] + // The stable kill switch wins over everything, including a legacy opt-in and an explicit source. + [InlineData("false", null, null, FeatureFlagsSource.Disabled)] + [InlineData("false", null, "true", FeatureFlagsSource.Disabled)] + [InlineData("false", "agentless", null, FeatureFlagsSource.Disabled)] + [InlineData("false", "remote_config", null, FeatureFlagsSource.Disabled)] + // Enabling explicitly does not imply the historical Remote Configuration source. + [InlineData("true", null, null, FeatureFlagsSource.Agentless)] + // An explicit source wins over the legacy key, in both directions. + [InlineData(null, "agentless", "true", FeatureFlagsSource.Agentless)] + [InlineData(null, "remote_config", "false", FeatureFlagsSource.RemoteConfig)] + // The legacy key grandfathers existing adopters, who opted in when RC was the only source. + [InlineData(null, null, "true", FeatureFlagsSource.RemoteConfig)] + [InlineData(null, null, "false", FeatureFlagsSource.Disabled)] + // An explicit new-key value takes precedence over the legacy key, so a stale legacy disable + // does not silently keep Feature Flags off during migration. + [InlineData("true", null, "false", FeatureFlagsSource.Agentless)] + [InlineData("true", null, "true", FeatureFlagsSource.Agentless)] + // An unrecognised source fails closed rather than guessing a billed delivery path. + [InlineData(null, "invalid", null, FeatureFlagsSource.Disabled)] + [InlineData(null, "invalid", "true", FeatureFlagsSource.Disabled)] + // "offline" is a reserved, recognised fail-closed sentinel (not an unrecognised value). + [InlineData(null, "offline", null, FeatureFlagsSource.Disabled)] + [InlineData(null, "offline", "true", FeatureFlagsSource.Disabled)] + public void ResolvesSource(string? enabled, string? source, string? legacyEnabled, object expected) + { + var expectedSource = (FeatureFlagsSource)expected; + var settings = CreateSettings(enabled, source, legacyEnabled); + + settings.Source.Should().Be(expectedSource); + settings.Enabled.Should().Be(expectedSource != FeatureFlagsSource.Disabled); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void TreatsBlankSourceAsUnset(string source) + { + CreateSettings(enabled: null, source: source, legacyEnabled: null) + .Source.Should().Be(FeatureFlagsSource.Agentless); + + // Being semantically unset, a blank source still lets the legacy key grandfather RC. + CreateSettings(enabled: null, source: source, legacyEnabled: "true") + .Source.Should().Be(FeatureFlagsSource.RemoteConfig); + } + + [Theory] + [InlineData("AGENTLESS", FeatureFlagsSource.Agentless)] + [InlineData(" Remote_Config ", FeatureFlagsSource.RemoteConfig)] + public void NormalizesSourceCasingAndWhitespace(string source, object expected) + => CreateSettings(enabled: null, source: source, legacyEnabled: null).Source.Should().Be((FeatureFlagsSource)expected); + + [Fact] + public void UsesDocumentedDefaults() + { + var settings = CreateSettings(null, null, null); + + settings.PollInterval.Should().Be(TimeSpan.FromSeconds(30)); + settings.RequestTimeout.Should().Be(TimeSpan.FromSeconds(5)); + settings.InitializationTimeout.Should().Be(TimeSpan.FromMilliseconds(10_000)); + settings.AgentlessBaseUrl.Should().BeNull(); + } + + [Theory] + [InlineData("60", 60)] + [InlineData("3600", 3600)] + // Out of range values are rejected in favour of the default: a non-positive interval would + // turn polling into a tight loop, and an implausibly large one is a misconfiguration. + [InlineData("0", 30)] + [InlineData("-1", 30)] + [InlineData("3601", 30)] + [InlineData("not-a-number", 30)] + public void ReadsPollInterval(string configured, int expectedSeconds) + { + var settings = CreateSettings( + null, + null, + null, + (ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSourceAgentlessPollIntervalSeconds, configured)); + + settings.PollInterval.Should().Be(TimeSpan.FromSeconds(expectedSeconds)); + } + + [Theory] + [InlineData("1", 1)] + [InlineData("0", 5)] + [InlineData("-2", 5)] + public void ReadsRequestTimeout(string configured, int expectedSeconds) + { + var settings = CreateSettings( + null, + null, + null, + (ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSourceAgentlessRequestTimeoutSeconds, configured)); + + settings.RequestTimeout.Should().Be(TimeSpan.FromSeconds(expectedSeconds)); + } + + [Theory] + [InlineData("1000", 1000)] + [InlineData("0", 10_000)] + [InlineData("-1", 10_000)] + public void ReadsInitializationTimeout(string configured, int expectedMs) + { + var settings = CreateSettings( + null, + null, + null, + (ConfigurationKeys.FeatureFlags.FlaggingProviderInitializationTimeoutMs, configured)); + + settings.InitializationTimeout.Should().Be(TimeSpan.FromMilliseconds(expectedMs)); + } + + [Theory] + [InlineData("https://flags.example.com/ufc", "https://flags.example.com/ufc")] + [InlineData("", null)] + [InlineData(" ", null)] + public void ReadsAgentlessBaseUrl(string configured, string? expected) + { + var settings = CreateSettings( + null, + null, + null, + (ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSourceAgentlessBaseUrl, configured)); + + settings.AgentlessBaseUrl.Should().Be(expected); + } + + private static FeatureFlagsSettings CreateSettings( + string? enabled, + string? source, + string? legacyEnabled, + params (string Key, string Value)[] extra) + { + var collection = new NameValueCollection(); + + if (enabled is not null) + { + collection[ConfigurationKeys.FeatureFlags.FeatureFlagsEnabled] = enabled; + } + + if (source is not null) + { + collection[ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSource] = source; + } + + if (legacyEnabled is not null) + { +#pragma warning disable 618 // superseded, but still honoured for existing adopters + collection[ConfigurationKeys.FeatureFlags.FlaggingProviderEnabled] = legacyEnabled; +#pragma warning restore 618 + } + + foreach (var (key, value) in extra) + { + collection[key] = value; + } + + return new FeatureFlagsSettings(new NameValueConfigurationSource(collection), NullConfigurationTelemetry.Instance); + } +}