diff --git a/tracer/src/Datadog.Trace/Configuration/TracerSettings.cs b/tracer/src/Datadog.Trace/Configuration/TracerSettings.cs index 6e04e7a41e71..6051c80536d2 100644 --- a/tracer/src/Datadog.Trace/Configuration/TracerSettings.cs +++ b/tracer/src/Datadog.Trace/Configuration/TracerSettings.cs @@ -15,6 +15,7 @@ using Datadog.Trace.Configuration.ConfigurationSources.Telemetry; using Datadog.Trace.Configuration.Telemetry; using Datadog.Trace.DataStreamsMonitoring.TransactionTracking; +using Datadog.Trace.FeatureFlags; using Datadog.Trace.Logging; using Datadog.Trace.Logging.DirectSubmission; using Datadog.Trace.PlatformHelpers; @@ -888,6 +889,11 @@ not null when string.Equals(value, "otlp", StringComparison.OrdinalIgnoreCase) = Manager = new(source, this, telemetry, errorLog); + // The environment is deliberately not passed in: it can be changed in code after + // startup, so the delivery source subscribes to the manager and applies the current + // value per request instead of capturing one here. + FeatureFlags = new FeatureFlagsSettings(source, telemetry); + // OTLP span metrics require OTLP trace export (see TracerManagerFactory.GetAgentWriter). // Force to false otherwise, even if explicitly requested. if (OtelTracesSpanMetricsEnabled && !Manager.InitialExporterSettings.IsOtlpTraceExport) @@ -1500,6 +1506,11 @@ not null when string.Equals(value, "otlp", StringComparison.OrdinalIgnoreCase) = /// internal bool IsSpanEnrichmentEnabled { get; } + /// + /// Gets the Feature Flags settings, which select where flag configuration is delivered from. + /// + internal FeatureFlagsSettings FeatureFlags { get; } + /// /// Gets a value indicating whether partial flush is enabled /// diff --git a/tracer/src/Datadog.Trace/Configuration/supported-configurations.yaml b/tracer/src/Datadog.Trace/Configuration/supported-configurations.yaml index cba391e5643a..de6472239a3b 100644 --- a/tracer/src/Datadog.Trace/Configuration/supported-configurations.yaml +++ b/tracer/src/Datadog.Trace/Configuration/supported-configurations.yaml @@ -3157,6 +3157,85 @@ supportedConfigurations: documentation: |- Enables Feature Flags Provider (Experimental). Default value is false (disabled). + DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS: + - implementation: A + scope: managed + type: int + default: '30000' + 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 30000 (30 seconds), matching the other tracers. + 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), + remote_config (delivery through the Datadog Agent's Remote Configuration) + and offline, which disables Feature Flags and contacts nothing. + Any other value is rejected and the default applies. + DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL: + - implementation: A + scope: managed + type: string + sensitive: true + default: null + product: FeatureFlags + const_name: FeatureFlagsConfigurationSourceAgentlessBaseUrl + documentation: |- + Configuration key for overriding the endpoint used by the agentless configuration source. + If unset, the endpoint is derived from and Datadog hosts it. + A configured URL is treated as an endpoint of your own, which changes three things: + the Datadog API key is not sent to it, so it is responsible for its own authentication; + it is requested exactly as written, so dd_env is not added and any environment or tenant + scope has to be part of the URL you configure; + and only its path is completed, when it has none or a path of /, with the standard + rules-based server path. Any other path is used verbatim as the exact endpoint. + The value may carry credentials, so it is never written to logs or telemetry. + 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..08868b22573d --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/Agentless/AgentlessEndpoint.cs @@ -0,0 +1,186 @@ +// +// 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 Datadog.Trace.Processors; +using Datadog.Trace.Util; + +namespace Datadog.Trace.FeatureFlags.Agentless; + +/// +/// The agentless endpoint, derived from the Datadog site or a custom base URL. A class rather +/// than a struct so that "no endpoint" is null instead of a default instance whose +/// non-nullable is null; it is built once per process, so the allocation is free. +/// +internal sealed class 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."; + + /// + /// The query parameter carrying the environment to request configuration for. Added to the + /// managed endpoint only. + /// + internal const string EnvParameterName = "dd_env"; + + private AgentlessEndpoint(Uri uri, bool isManaged) + { + Uri = uri; + IsManaged = isManaged; + } + + /// + /// Gets the endpoint URI, without the environment. Use to get the + /// URI to request. + /// + 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 leaked to 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 is the only endpoint dd_env is added to. A custom base URL that + /// is an origin receives the canonical path; one that carries a path is used verbatim. + /// + /// The environment is not part of the endpoint: it can be changed in code while the application + /// runs, so it is applied per request by instead. + /// + /// + /// The Datadog site, for example datadoghq.com. + /// The configured endpoint override, or null. + /// The resulting endpoint, or null when none could be built. + /// 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? baseUrl, [NotNullWhen(true)] out AgentlessEndpoint? endpoint, out string? error) + { + endpoint = null; + 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; + } + + // 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 + trimmedSite.ToLowerInvariant(); + + if (!Uri.TryCreate($"https://{managedHost}{DefaultPath}", UriKind.Absolute, out var managedUri)) + { + error = "The configured Datadog site is not valid"; + return false; + } + + endpoint = new AgentlessEndpoint(managedUri, isManaged: true); + return true; + } + + // Uri parsing rejects whitespace inside a host, but accepts it in a path or query, so a + // URL carrying it there is malformed and has to be caught explicitly. + 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; + } + + /// + /// 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 + /// the application runs. + /// + /// A configured base URL is opaque: it is requested exactly as the operator wrote it, since it + /// may hold credentials, routing, or a scope of its own. Only the managed endpoint carries + /// dd_env, which matches the other tracers. + /// + /// + /// The current environment, or null when none is configured. + /// The URI to request. + public Uri BuildRequestUri(string? env) + { + if (!IsManaged) + { + return Uri; + } + + // Normalized the same way the tracer normalizes it before tagging spans, so that flag + // targeting and span tags agree on what the environment is. It also bounds the value at 200 + // characters, which keeps a misconfigured environment from producing an unusable URL. + // A value that normalizes to nothing is treated as no environment at all. + var normalized = TraceUtil.NormalizeTag(env); + if (StringUtil.IsNullOrEmpty(normalized)) + { + return Uri; + } + + var parameter = EnvParameterName + "=" + Uri.EscapeDataString(normalized); + var builder = new UriBuilder(Uri); + + // The getter returns the query with its leading "?", while the setter prepends one of its + // own on .NET Framework, so the existing query is trimmed before it is extended. A URL + // ending in a bare "?" reports a query of "?", which the length check treats as no query. + var existing = builder.Query; + builder.Query = existing.Length > 1 ? existing.TrimStart('?') + "&" + parameter : parameter; + return builder.Uri; + } +} diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsSettings.cs b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsSettings.cs new file mode 100644 index 000000000000..9bbb0aa7234b --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsSettings.cs @@ -0,0 +1,202 @@ +// +// 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.ConfigurationSources.Telemetry; +using Datadog.Trace.Configuration.Telemetry; +using Datadog.Trace.Logging; +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; + // Matches the Go, Java and Node tracers, so the same slow first configuration does not give + // one language the caller's default value while the others still return a real one. + internal const int DefaultInitializationTimeoutMs = 30_000; + + // One hour. An interval above this is more likely a misconfiguration (for example milliseconds + // passed as seconds) than an intent, and the other tracers cap it at the same value. + 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 + + 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 + } + + // Where configuration comes from is a separate question from whether the product runs, and + // the two are answered separately below, as the other tracers answer them. + // + // The source key is read once, with every other outcome expressed as its default, so + // configuration telemetry reports the one value we act on rather than one entry per + // candidate key. Shared across tracers, so the precedence is deliberate: an explicit source + // wins over the legacy key, the legacy key grandfathers existing adopters onto Remote + // Configuration, and everything else defaults to agentless. + // net461 has no System.ValueTuple, so a tuple pattern over both values does not compile. + DefaultResult defaultSource = enabled switch + { + null when legacyEnabled is not null => legacyEnabled.Value + ? new(FeatureFlagsSource.RemoteConfig, RemoteConfigSourceName) + : new(FeatureFlagsSource.Offline, OfflineSourceName), + _ => new(FeatureFlagsSource.Agentless, AgentlessSourceName), + }; + + Source = config + .WithKeys(ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSource) + .GetAs(defaultSource, validator: null, converter: ConvertSource); + + // The stable kill switch turns the product off whatever the source says. Beyond that, only a + // delivery source has anything to run: offline delivers nothing, and an unrecognised value + // resolves to offline so that a typo fails closed instead of starting billed delivery. + Enabled = enabled != false + && Source is FeatureFlagsSource.Agentless or FeatureFlagsSource.RemoteConfig; + + var agentlessBaseUrl = config + .WithKeys(ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSourceAgentlessBaseUrl) + .AsRedactedString(); + AgentlessBaseUrl = !StringUtil.IsNullOrWhiteSpace(agentlessBaseUrl) ? agentlessBaseUrl : null; + + PollInterval = TimeSpan.FromSeconds( + config.WithKeys(ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSourceAgentlessPollIntervalSeconds) + .AsInt32(DefaultPollIntervalSeconds, v => v > 0 && v <= MaxPollIntervalSeconds) + .Value); + + RequestTimeout = TimeSpan.FromSeconds( + config.WithKeys(ConfigurationKeys.FeatureFlags.FeatureFlagsConfigurationSourceAgentlessRequestTimeoutSeconds) + .AsInt32(DefaultRequestTimeoutSeconds, v => v > 0) + .Value); + + // DD_SITE and DD_API_KEY are not extracted on TracerSettings, so they are read here as the + // other product settings do (TelemetrySettings, DirectLogSubmissionSettings). DD_ENV is not + // read at all: it can be changed in code while the application runs, so the delivery source + // takes the current value per request rather than capturing one here. + Site = config + .WithKeys(ConfigurationKeys.Site) + .AsString(DefaultSite, static site => !StringUtil.IsNullOrWhiteSpace(site)); + 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 run at all. Independent of , + /// which says where configuration would come from. + /// + public bool Enabled { get; } + + /// + /// 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 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; } + + /// + /// Converts a configured source name to a . A blank value is + /// treated as unset and falls back to the default, which is what an absent key would have + /// selected anyway. An unrecognised value fails closed instead: nothing is contacted. + /// + private static ParsingResult ConvertSource(string? value) + { + if (StringUtil.IsNullOrWhiteSpace(value)) + { + return ParsingResult.Failure(); + } + + var trimmed = value.Trim(); + + if (string.Equals(trimmed, AgentlessSourceName, StringComparison.OrdinalIgnoreCase)) + { + return ParsingResult.Success(FeatureFlagsSource.Agentless); + } + + if (string.Equals(trimmed, RemoteConfigSourceName, StringComparison.OrdinalIgnoreCase)) + { + return ParsingResult.Success(FeatureFlagsSource.RemoteConfig); + } + + // "offline" is a reserved sentinel: the provider is intentionally off. + if (string.Equals(trimmed, OfflineSourceName, StringComparison.OrdinalIgnoreCase)) + { + return ParsingResult.Success(FeatureFlagsSource.Offline); + } + + // A value nobody recognises fails closed rather than falling back to agentless: guessing a + // billed delivery path from a typo is worse than delivering nothing. Shared across tracers, + // and asserted by the system-tests parametric suite. + Log.Warning( + "Unsupported Feature Flags configuration source {Source}. No configuration will be delivered.", + value); + + return ParsingResult.Success(FeatureFlagsSource.Offline); + } +} diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsSource.cs b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsSource.cs new file mode 100644 index 000000000000..3c85ae61433f --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsSource.cs @@ -0,0 +1,30 @@ +// +// 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 +{ + /// + /// Selected by offline. Nothing is contacted and no configuration is loaded, so + /// evaluations return the caller's default value. + /// + Offline, + + /// + /// 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..288cd8f848eb 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,66 @@ 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 30000 (30 seconds), matching the other tracers. + /// 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), + /// remote_config (delivery through the Datadog Agent's Remote Configuration) + /// and offline, which disables Feature Flags and contacts nothing. + /// Any other value is rejected and the default applies. + /// + public const string FeatureFlagsConfigurationSource = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE"; + + /// + /// Configuration key for overriding the endpoint used by the agentless configuration source. + /// If unset, the endpoint is derived from and Datadog hosts it. + /// A configured URL is treated as an endpoint of your own, which changes three things: + /// the Datadog API key is not sent to it, so it is responsible for its own authentication; + /// it is requested exactly as written, so dd_env is not added and any environment or tenant + /// scope has to be part of the URL you configure; + /// and only its path is completed, when it has none or a path of /, with the standard + /// rules-based server path. Any other path is used verbatim as the exact endpoint. + /// The value may carry credentials, so it is never written to logs or telemetry. + /// + 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..288cd8f848eb 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,66 @@ 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 30000 (30 seconds), matching the other tracers. + /// 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), + /// remote_config (delivery through the Datadog Agent's Remote Configuration) + /// and offline, which disables Feature Flags and contacts nothing. + /// Any other value is rejected and the default applies. + /// + public const string FeatureFlagsConfigurationSource = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE"; + + /// + /// Configuration key for overriding the endpoint used by the agentless configuration source. + /// If unset, the endpoint is derived from and Datadog hosts it. + /// A configured URL is treated as an endpoint of your own, which changes three things: + /// the Datadog API key is not sent to it, so it is responsible for its own authentication; + /// it is requested exactly as written, so dd_env is not added and any environment or tenant + /// scope has to be part of the URL you configure; + /// and only its path is completed, when it has none or a path of /, with the standard + /// rules-based server path. Any other path is used verbatim as the exact endpoint. + /// The value may carry credentials, so it is never written to logs or telemetry. + /// + 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..288cd8f848eb 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,66 @@ 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 30000 (30 seconds), matching the other tracers. + /// 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), + /// remote_config (delivery through the Datadog Agent's Remote Configuration) + /// and offline, which disables Feature Flags and contacts nothing. + /// Any other value is rejected and the default applies. + /// + public const string FeatureFlagsConfigurationSource = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE"; + + /// + /// Configuration key for overriding the endpoint used by the agentless configuration source. + /// If unset, the endpoint is derived from and Datadog hosts it. + /// A configured URL is treated as an endpoint of your own, which changes three things: + /// the Datadog API key is not sent to it, so it is responsible for its own authentication; + /// it is requested exactly as written, so dd_env is not added and any environment or tenant + /// scope has to be part of the URL you configure; + /// and only its path is completed, when it has none or a path of /, with the standard + /// rules-based server path. Any other path is used verbatim as the exact endpoint. + /// The value may carry credentials, so it is never written to logs or telemetry. + /// + 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..288cd8f848eb 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,66 @@ 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 30000 (30 seconds), matching the other tracers. + /// 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), + /// remote_config (delivery through the Datadog Agent's Remote Configuration) + /// and offline, which disables Feature Flags and contacts nothing. + /// Any other value is rejected and the default applies. + /// + public const string FeatureFlagsConfigurationSource = "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE"; + + /// + /// Configuration key for overriding the endpoint used by the agentless configuration source. + /// If unset, the endpoint is derived from and Datadog hosts it. + /// A configured URL is treated as an endpoint of your own, which changes three things: + /// the Datadog API key is not sent to it, so it is responsible for its own authentication; + /// it is requested exactly as written, so dd_env is not added and any environment or tenant + /// scope has to be part of the URL you configure; + /// and only its path is completed, when it has none or a path of /, with the standard + /// rules-based server path. Any other path is used verbatim as the exact endpoint. + /// The value may carry credentials, so it is never written to logs or telemetry. + /// + 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..6e024bfe7456 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/AgentlessEndpointTests.cs @@ -0,0 +1,173 @@ +// +// 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) + { + var endpoint = Create(site); + + endpoint.IsManaged.Should().BeTrue(); + endpoint.Uri.ToString().Should().Be(expected); + } + + [Fact] + public void EndpointItselfCarriesNoEnvironment() + => Create("datadoghq.com").Uri.Query.Should().BeEmpty(); + + [Fact] + public void AddsDdEnvWhenEnvIsConfigured() + => Create("datadoghq.com").BuildRequestUri("production").Query.Should().Be("?dd_env=production"); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + // The tracer's tag normalization requires a leading letter, so a value that is entirely digits + // normalizes away. Spans are tagged the same way, so no environment is reported either. + [InlineData("2024")] + public void DoesNotAddDdEnvWhenEnvIsNotConfigured(string? env) + => Create("datadoghq.com").BuildRequestUri(env).Query.Should().BeEmpty(); + + [Theory] + // Normalized exactly as the tracer normalizes the environment before tagging spans: lowercased, + // with runs of unsupported characters collapsed to a single underscore. + [InlineData("Production", "production")] + [InlineData("Prod EU", "prod_eu")] + [InlineData(" staging ", "staging")] + [InlineData("my env&test", "my_env_test")] + public void NormalizesDdEnvValue(string env, string expected) + => Create("datadoghq.com").BuildRequestUri(env).Query.Should().Be("?dd_env=" + expected); + + [Fact] + public void EscapesDdEnvValue() + // "/" survives normalization but cannot be carried unescaped in a query value. + => Create("datadoghq.com").BuildRequestUri("team/a").Query.Should().Be("?dd_env=team%2Fa"); + + [Fact] + public void TruncatesAnOverlongDdEnvValue() + => Create("datadoghq.com").BuildRequestUri(new string('a', 500)) + .Query.Should().Be("?dd_env=" + new string('a', 200)); + + [Theory] + // A configured base URL is opaque: the operator may have scoped it themselves, and it may carry + // credentials or routing, so it is requested exactly as written. Java, JS, Go and Python all + // treat a custom endpoint the same way. + [InlineData("https://flags.example.com/ufc")] + [InlineData("https://flags.example.com/ufc?token=abc")] + [InlineData("https://flags.example.com/ufc?dd_env=staging")] + [InlineData("https://flags.example.com/ufc?token=abc&dd_env=staging")] + public void DoesNotAddDdEnvToACustomEndpoint(string baseUrl) + { + var endpoint = Create("datadoghq.com", baseUrl); + + endpoint.BuildRequestUri("production").Should().Be(endpoint.Uri); + } + + [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) + { + var endpoint = Create("datadoghq.com", baseUrl: baseUrl); + + 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) + => Create("datadoghq.com", baseUrl: baseUrl).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", baseUrl: baseUrl, out var endpoint, out var error) + .Should().BeFalse(); + error.Should().Be(expectedError); + endpoint.Should().BeNull(); + } + + [Fact] + public void RejectsEmptySiteWithoutBaseUrl() + { + AgentlessEndpoint.TryCreate(site: null, baseUrl: null, out var endpoint, out var error) + .Should().BeFalse(); + error.Should().Be("No Datadog site is configured"); + endpoint.Should().BeNull(); + } + + [Fact] + public void RejectsWhitespaceOnlySiteWithoutBaseUrl() + { + AgentlessEndpoint.TryCreate(" ", baseUrl: null, out var endpoint, out var error) + .Should().BeFalse(); + error.Should().Be("No Datadog site is configured"); + endpoint.Should().BeNull(); + } + + [Theory] + [InlineData("https://datadoghq.com")] // user accidentally includes the scheme + [InlineData("data dog hq.com")] // internal spaces + [InlineData("datadoghq.com:99999")] // invalid port + // "@" would end the userinfo and make the remainder the real host, so the request, and with it + // the API key, would go to a host the operator never named. + [InlineData("datadoghq.com@attacker.example")] + [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 + public void RejectsMalformedSiteWithoutThrowing(string site) + { + AgentlessEndpoint.TryCreate(site, baseUrl: null, out var endpoint, out var error) + .Should().BeFalse(); + error.Should().Be("The configured Datadog site is not valid"); + endpoint.Should().BeNull(); + } + + [Fact] + public void ErrorNeverContainsUrl() + { + // A URL may carry credentials, so the error must never echo it. + AgentlessEndpoint.TryCreate("datadoghq.com", baseUrl: "https://user:pass@flags.example.com bad", out _, out var error) + .Should().BeFalse(); + error.Should().NotContain("user"); + error.Should().NotContain("pass"); + } + + // Builds an endpoint that is expected to be valid, and returns it as non-nullable so the + // assertions can read it directly. Throwing rather than asserting keeps the compiler's nullable + // analysis satisfied without a null-forgiving operator, which would let an assertion be + // silently skipped if the endpoint were ever null. + private static AgentlessEndpoint Create(string? site, string? baseUrl = null) + { + AgentlessEndpoint.TryCreate(site, baseUrl, out var endpoint, out var error) + .Should().BeTrue(); + error.Should().BeNull(); + + return endpoint ?? throw new InvalidOperationException("TryCreate reported success without producing an endpoint."); + } +} 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..79d67eedb464 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FeatureFlagsSettingsTests.cs @@ -0,0 +1,209 @@ +// +// 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). + // Where configuration would come from and whether Feature Flags run are asserted separately: + // the kill switch turns the product off without changing the source it would have used. + [Theory] + // Nothing configured: agentless is the default. + [InlineData(null, null, null, FeatureFlagsSource.Agentless, true)] + // The stable kill switch wins over everything, including a legacy opt-in and an explicit source. + [InlineData("false", null, null, FeatureFlagsSource.Agentless, false)] + [InlineData("false", null, "true", FeatureFlagsSource.Agentless, false)] + [InlineData("false", "agentless", null, FeatureFlagsSource.Agentless, false)] + [InlineData("false", "remote_config", null, FeatureFlagsSource.RemoteConfig, false)] + // Enabling explicitly does not imply the historical Remote Configuration source. + [InlineData("true", null, null, FeatureFlagsSource.Agentless, true)] + // An explicit source wins over the legacy key, in both directions. + [InlineData(null, "agentless", "true", FeatureFlagsSource.Agentless, true)] + [InlineData(null, "remote_config", "false", FeatureFlagsSource.RemoteConfig, true)] + // The legacy key grandfathers existing adopters, who opted in when RC was the only source. + [InlineData(null, null, "true", FeatureFlagsSource.RemoteConfig, true)] + [InlineData(null, null, "false", FeatureFlagsSource.Offline, false)] + // 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, true)] + [InlineData("true", null, "true", FeatureFlagsSource.Agentless, true)] + // An unrecognised source fails closed, and does so before the legacy key is considered: starting + // billed delivery off a typo is worse than delivering nothing. Java and JS resolve it the same + // way, and the system-tests parametric suite asserts no request is made. + [InlineData(null, "invalid", null, FeatureFlagsSource.Offline, false)] + [InlineData(null, "invalid", "true", FeatureFlagsSource.Offline, false)] + // "offline" is a recognised source that delivers nothing, so there is nothing to run yet. + [InlineData(null, "offline", null, FeatureFlagsSource.Offline, false)] + [InlineData(null, "offline", "true", FeatureFlagsSource.Offline, false)] + public void ResolvesSource(string? enabled, string? source, string? legacyEnabled, object expected, bool expectedEnabled) + { + var settings = CreateSettings(enabled, source, legacyEnabled); + + settings.Source.Should().Be((FeatureFlagsSource)expected); + settings.Enabled.Should().Be(expectedEnabled); + } + + [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(30_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", 30_000)] + [InlineData("-1", 30_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); + } + + [Fact] + public void ReadsSiteAndApiKeyFromTheConfigurationSource() + { + var settings = CreateSettings( + null, + null, + null, + (ConfigurationKeys.Site, "datadoghq.eu"), + (ConfigurationKeys.ApiKey, "an-api-key")); + + settings.Site.Should().Be("datadoghq.eu"); + settings.ApiKey.Should().Be("an-api-key"); + } + + [Theory] + // A blank site is rejected in favour of the default, so the managed endpoint stays resolvable. + [InlineData("")] + [InlineData(" ")] + public void FallsBackToTheDefaultSite(string configured) + => CreateSettings(null, null, null, (ConfigurationKeys.Site, configured)) + .Site.Should().Be(FeatureFlagsSettings.DefaultSite); + + 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); + } +}