diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java index 18e9128f5e0..f5d80af171c 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java @@ -135,7 +135,7 @@ private enum AgentFeature { APP_LOGS_COLLECTION(GeneralConfig.APP_LOGS_COLLECTION_ENABLED, false), LLMOBS(LlmObsConfig.LLMOBS_ENABLED, false), LLMOBS_AGENTLESS(LlmObsConfig.LLMOBS_AGENTLESS_ENABLED, false), - FEATURE_FLAGGING(FeatureFlaggingConfig.FLAGGING_PROVIDER_ENABLED, false); + FEATURE_FLAGGING(FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED, true); private final String configKey; private final String systemProp; @@ -282,7 +282,7 @@ public static void start( agentlessLogSubmissionEnabled = isFeatureEnabled(AgentFeature.AGENTLESS_LOG_SUBMISSION); appLogsCollectionEnabled = isFeatureEnabled(AgentFeature.APP_LOGS_COLLECTION); llmObsEnabled = isFeatureEnabled(AgentFeature.LLMOBS); - featureFlaggingEnabled = isFeatureEnabled(AgentFeature.FEATURE_FLAGGING); + featureFlaggingEnabled = isFeatureFlaggingEnabled(); // setup writers when llmobs is enabled to accomodate apm and llmobs if (llmObsEnabled) { @@ -1648,6 +1648,45 @@ private static boolean isFeatureEnabled(AgentFeature feature) { } } + private static boolean isFeatureFlaggingEnabled() { + final Boolean providerEnabled = + featureFlaggingBooleanSetting(FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED); + final String configurationSource = + featureFlaggingSetting(FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE); + final Boolean legacyProviderEnabled = + featureFlaggingBooleanSetting(FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED); + + return FeatureFlaggingConfig.resolveConfiguration( + providerEnabled, configurationSource, legacyProviderEnabled) + .isEnabled(); + } + + @SuppressFBWarnings( + value = "NP_BOOLEAN_RETURN_NULL", + justification = "A null value preserves the distinction between absent and explicitly false") + private static Boolean featureFlaggingBooleanSetting(final String configKey) { + final String value = featureFlaggingSetting(configKey); + if (value == null) { + return null; + } + return Boolean.parseBoolean(value) || "1".equals(value); + } + + private static String featureFlaggingSetting(final String configKey) { + final String systemProperty = propertyNameToSystemPropertyName(configKey); + String value = SystemProperties.get(systemProperty); + if (value == null) { + value = getStableConfig(FLEET, configKey); + } + if (value == null) { + value = ddGetEnv(systemProperty); + } + if (value == null) { + value = getStableConfig(LOCAL, configKey); + } + return value; + } + /** * @see datadog.trace.api.ProductActivation#fromString(String) */ diff --git a/internal-api/src/main/java/datadog/trace/api/Config.java b/internal-api/src/main/java/datadog/trace/api/Config.java index 368e666c701..7957b89f75c 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -86,7 +86,6 @@ import static datadog.trace.api.ConfigDefaults.DEFAULT_ELASTICSEARCH_BODY_ENABLED; import static datadog.trace.api.ConfigDefaults.DEFAULT_ELASTICSEARCH_PARAMS_ENABLED; import static datadog.trace.api.ConfigDefaults.DEFAULT_EXPERIMENTATAL_JEE_SPLIT_BY_DEPLOYMENT; -import static datadog.trace.api.ConfigDefaults.DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE; import static datadog.trace.api.ConfigDefaults.DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS; import static datadog.trace.api.ConfigDefaults.DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS; import static datadog.trace.api.ConfigDefaults.DEFAULT_GRPC_CLIENT_ERROR_STATUSES; @@ -726,10 +725,14 @@ import static datadog.trace.api.config.TracerConfig.WRITER_BAGGAGE_INJECT; import static datadog.trace.api.config.TracerConfig.WRITER_LINKS_INJECT; import static datadog.trace.api.config.TracerConfig.WRITER_TYPE; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.isSupportedConfigurationSource; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.resolveConfiguration; import static datadog.trace.api.telemetry.LogCollector.SEND_TELEMETRY; import static datadog.trace.bootstrap.instrumentation.api.WriterConstants.OTLP_WRITER_TYPE; import static datadog.trace.util.CollectionUtils.tryMakeImmutableList; @@ -746,6 +749,7 @@ import datadog.trace.api.config.OtlpConfig; import datadog.trace.api.config.ProfilingConfig; import datadog.trace.api.config.TracerConfig; +import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; import datadog.trace.api.iast.IastContext; import datadog.trace.api.iast.IastDetectionMode; import datadog.trace.api.iast.telemetry.Verbosity; @@ -1221,6 +1225,7 @@ public static String getHostName() { private final int remoteConfigMaxExtraServices; + private final boolean featureFlaggingProviderEnabled; private final String featureFlaggingConfigurationSource; private final String featureFlaggingConfigurationSourceAgentlessBaseUrl; private final int featureFlaggingConfigurationSourcePollIntervalSeconds; @@ -2851,10 +2856,33 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) configProvider.getInteger( REMOTE_CONFIG_MAX_EXTRA_SERVICES, DEFAULT_REMOTE_CONFIG_MAX_EXTRA_SERVICES); - featureFlaggingConfigurationSource = - normalizeFeatureFlaggingConfigurationSource( - configProvider.getString( - FEATURE_FLAGS_CONFIGURATION_SOURCE, DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE)); + final boolean configuredFeatureFlaggingProviderEnabled = + configProvider.getBoolean(FEATURE_FLAGS_ENABLED, true); + final Boolean legacyFeatureFlaggingProviderEnabled = + configProvider.getBoolean(EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED); + final String configuredFeatureFlaggingConfigurationSource = + configProvider.isSet(FEATURE_FLAGS_CONFIGURATION_SOURCE) + ? configProvider.getString(FEATURE_FLAGS_CONFIGURATION_SOURCE) + : null; + final FeatureFlaggingConfig.Resolution resolvedFeatureFlaggingConfiguration = + resolveConfiguration( + configuredFeatureFlaggingProviderEnabled, + configuredFeatureFlaggingConfigurationSource, + legacyFeatureFlaggingProviderEnabled); + if (legacyFeatureFlaggingProviderEnabled != null) { + log.warn( + "Setting {} is deprecated. Use {} and {} instead.", + EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED, + FEATURE_FLAGS_ENABLED, + FEATURE_FLAGS_CONFIGURATION_SOURCE); + } + if (!isSupportedConfigurationSource(configuredFeatureFlaggingConfigurationSource)) { + log.warn( + "Unsupported Feature Flagging configuration source: {}. Disabling Feature Flagging", + configuredFeatureFlaggingConfigurationSource); + } + featureFlaggingProviderEnabled = resolvedFeatureFlaggingConfiguration.isEnabled(); + featureFlaggingConfigurationSource = resolvedFeatureFlaggingConfiguration.getSource(); featureFlaggingConfigurationSourceAgentlessBaseUrl = configProvider.getStringNotEmpty( FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, null); @@ -3797,28 +3825,6 @@ public boolean isInferredProxyPropagationEnabled() { return traceInferredProxyEnabled; } - private static String normalizeFeatureFlaggingConfigurationSource(final String source) { - if (source == null) { - return DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE; - } - final String normalized = source.trim().toLowerCase(Locale.ROOT); - if (normalized.isEmpty()) { - return DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE; - } - switch (normalized) { - case "agentless": - case "remote_config": - case "offline": - return normalized; - default: - log.warn( - "Unsupported Feature Flagging configuration source: {}. Defaulting to {}", - source, - DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE); - return DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE; - } - } - public boolean isBaggageExtract() { return tracePropagationStylesToExtract.contains(TracePropagationStyle.BAGGAGE); } @@ -4736,6 +4742,10 @@ public int getRemoteConfigMaxExtraServices() { return remoteConfigMaxExtraServices; } + public boolean isFeatureFlaggingProviderEnabled() { + return featureFlaggingProviderEnabled; + } + public String getFeatureFlaggingConfigurationSource() { return featureFlaggingConfigurationSource; } @@ -6584,6 +6594,8 @@ public String toString() { + remoteConfigMaxPayloadSize + ", remoteConfigIntegrityCheckEnabled=" + remoteConfigIntegrityCheckEnabled + + ", featureFlaggingProviderEnabled=" + + featureFlaggingProviderEnabled + ", featureFlaggingConfigurationSource=" + featureFlaggingConfigurationSource + ", featureFlaggingConfigurationSourceAgentlessBaseUrl=" diff --git a/internal-api/src/test/groovy/datadog/trace/api/ConfigTest.groovy b/internal-api/src/test/groovy/datadog/trace/api/ConfigTest.groovy index 7227597108b..1f19549c92a 100644 --- a/internal-api/src/test/groovy/datadog/trace/api/ConfigTest.groovy +++ b/internal-api/src/test/groovy/datadog/trace/api/ConfigTest.groovy @@ -57,9 +57,11 @@ import static datadog.trace.api.config.GeneralConfig.TAGS import static datadog.trace.api.config.GeneralConfig.TRACER_METRICS_IGNORED_RESOURCES import static datadog.trace.api.config.GeneralConfig.TRACE_OTEL_SEMANTICS_ENABLED import static datadog.trace.api.config.GeneralConfig.VERSION +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED import static datadog.trace.api.config.JmxFetchConfig.JMX_FETCH_CHECK_PERIOD import static datadog.trace.api.config.JmxFetchConfig.JMX_FETCH_ENABLED import static datadog.trace.api.config.JmxFetchConfig.JMX_FETCH_METRICS_CONFIGS @@ -3517,7 +3519,39 @@ class ConfigTest extends DDSpecification { "" | "agentless" " " | "agentless" " ReMoTe_ConFiG " | "remote_config" - "not-a-real-source" | "agentless" + "not-a-real-source" | null + } + + def "feature flag configuration applies migration precedence"() { + setup: + Properties properties = new Properties() + if (providerEnabled != null) { + properties.setProperty(FEATURE_FLAGS_ENABLED, providerEnabled.toString()) + } + if (source != null) { + properties.setProperty(FEATURE_FLAGS_CONFIGURATION_SOURCE, source) + } + if (legacyProviderEnabled != null) { + properties.setProperty(EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED, legacyProviderEnabled.toString()) + } + + when: + def config = new Config(ConfigProvider.withPropertiesOverride(properties)) + + then: + config.featureFlaggingProviderEnabled == expectedEnabled + config.featureFlaggingConfigurationSource == expectedSource + + where: + providerEnabled | source | legacyProviderEnabled | expectedEnabled | expectedSource + null | null | null | true | "agentless" + null | null | true | true | "remote_config" + null | null | false | false | null + null | "agentless" | true | true | "agentless" + null | "remote_config" | false | true | "remote_config" + false | "agentless" | true | false | null + true | null | false | false | null + null | "not-a-source" | null | false | null } def "agentless feature flag timing falls back for non-positive values"() { diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 74a0bb8e67e..cb163a223c9 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -1497,6 +1497,14 @@ "aliases": [] } ], + "DD_FEATURE_FLAGS_ENABLED": [ + { + "version": "A", + "type": "boolean", + "default": "true", + "aliases": [] + } + ], "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE": [ { "version": "A", @@ -11986,5 +11994,7 @@ } ] }, - "deprecations": {} + "deprecations": { + "DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED": "DD_FEATURE_FLAGS_ENABLED" + } } diff --git a/products/feature-flagging/feature-flagging-agent/build.gradle.kts b/products/feature-flagging/feature-flagging-agent/build.gradle.kts index 0c008e373a3..d2dc4baea2b 100644 --- a/products/feature-flagging/feature-flagging-agent/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-agent/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { api(libs.slf4j) api(project(":products:feature-flagging:feature-flagging-lib")) api(project(":internal-api")) + compileOnly(project(":products:feature-flagging:feature-flagging-config")) testImplementation(libs.bundles.junit5) testImplementation(libs.bundles.mockito) diff --git a/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java b/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java index 5c9cb20540e..19aef4e6e46 100644 --- a/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java +++ b/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java @@ -1,7 +1,11 @@ package com.datadog.featureflag; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; + import datadog.communication.ddagent.SharedCommunicationObjects; import datadog.trace.api.Config; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -12,16 +16,59 @@ public class FeatureFlaggingSystem { private static volatile ConfigurationSourceService CONFIG_SERVICE; private static volatile ExposureWriter EXPOSURE_WRITER; private static volatile SpanEnrichmentWriter SPAN_ENRICHMENT_WRITER; + private static volatile FeatureFlaggingGateway.ActivationListener ACTIVATION_LISTENER; + private static volatile boolean STARTED; private FeatureFlaggingSystem() {} public static synchronized void start(final SharedCommunicationObjects sco) { - if (CONFIG_SERVICE != null || EXPOSURE_WRITER != null) { + if (STARTED) { LOGGER.debug("Feature Flagging system already started"); return; } LOGGER.debug("Feature Flagging system starting"); final Config config = Config.get(); + STARTED = true; + + if (!config.isFeatureFlaggingProviderEnabled()) { + LOGGER.debug("Feature Flagging system disabled"); + return; + } + final String source = config.getFeatureFlaggingConfigurationSource(); + if (CONFIGURATION_SOURCE_AGENTLESS.equals(source)) { + final FeatureFlaggingGateway.ActivationListener activationListener = + () -> activateAgentless(sco, config); + ACTIVATION_LISTENER = activationListener; + FeatureFlaggingGateway.addActivationListener(activationListener); + LOGGER.debug("Feature Flagging system awaiting application provider activation"); + return; + } + + try { + initializeSystem(sco, config); + } catch (final RuntimeException | Error e) { + STARTED = false; + throw e; + } + } + + private static synchronized void activateAgentless( + final SharedCommunicationObjects sco, final Config config) { + final FeatureFlaggingGateway.ActivationListener activationListener = ACTIVATION_LISTENER; + if (!STARTED || activationListener == null) { + return; + } + ACTIVATION_LISTENER = null; + FeatureFlaggingGateway.removeActivationListener(activationListener); + try { + initializeSystem(sco, config); + } catch (final RuntimeException | Error e) { + STARTED = false; + throw e; + } + } + + private static void initializeSystem(final SharedCommunicationObjects sco, final Config config) { final ConfigurationSourceService configService = createConfigurationSourceService(sco, config); final ExposureWriter exposureWriter = new ExposureWriterImpl(sco, config); initialize(configService, exposureWriter); @@ -60,30 +107,34 @@ static void initialize( static ConfigurationSourceService createConfigurationSourceService( final SharedCommunicationObjects sco, final Config config) { - final ConfigurationSource configurationSource = - ConfigurationSource.from(config.getFeatureFlaggingConfigurationSource()); + final String configurationSource = config.getFeatureFlaggingConfigurationSource(); - if (configurationSource == ConfigurationSource.REMOTE_CONFIG) { + if (CONFIGURATION_SOURCE_REMOTE_CONFIG.equals(configurationSource)) { if (!config.isRemoteConfigEnabled()) { throw new IllegalStateException("Feature Flagging system started without RC"); } return new RemoteConfigServiceImpl(sco, config); } - if (configurationSource == ConfigurationSource.AGENTLESS) { + if (CONFIGURATION_SOURCE_AGENTLESS.equals(configurationSource)) { return new AgentlessConfigurationSource(config); } - LOGGER.debug( - "Feature Flagging offline configuration source selected; no config service started"); - return null; + throw new IllegalArgumentException( + "Unsupported Feature Flagging configuration source: " + configurationSource); } public static synchronized void stop() { + final FeatureFlaggingGateway.ActivationListener activationListener = ACTIVATION_LISTENER; final SpanEnrichmentWriter spanEnrichmentWriter = SPAN_ENRICHMENT_WRITER; final ExposureWriter exposureWriter = EXPOSURE_WRITER; final ConfigurationSourceService configService = CONFIG_SERVICE; + STARTED = false; + ACTIVATION_LISTENER = null; SPAN_ENRICHMENT_WRITER = null; EXPOSURE_WRITER = null; CONFIG_SERVICE = null; + if (activationListener != null) { + FeatureFlaggingGateway.removeActivationListener(activationListener); + } try { if (spanEnrichmentWriter != null) { spanEnrichmentWriter.close(); @@ -102,25 +153,7 @@ public static synchronized void stop() { LOGGER.debug("Feature Flagging system stopped"); } - private enum ConfigurationSource { - AGENTLESS("agentless"), - REMOTE_CONFIG("remote_config"), - OFFLINE("offline"); - - private final String value; - - ConfigurationSource(final String value) { - this.value = value; - } - - private static ConfigurationSource from(final String value) { - for (final ConfigurationSource source : values()) { - if (source.value.equals(value)) { - return source; - } - } - throw new IllegalArgumentException( - "Unsupported Feature Flagging configuration source: " + value); - } + static boolean isAwaitingApplicationActivation() { + return ACTIVATION_LISTENER != null; } } diff --git a/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java b/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java index 523736d13db..c4183474267 100644 --- a/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java +++ b/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java @@ -2,10 +2,12 @@ import static datadog.trace.api.config.RemoteConfigConfig.REMOTE_CONFIGURATION_ENABLED; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; @@ -20,6 +22,7 @@ import datadog.remoteconfig.ConfigurationPoller; import datadog.remoteconfig.Product; import datadog.trace.api.Config; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.junit.utils.config.WithConfig; import okhttp3.HttpUrl; import okhttp3.OkHttpClient; @@ -27,6 +30,25 @@ class FeatureFlaggingSystemTest { + @Test + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") + @WithConfig( + key = FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, + value = "http://127.0.0.1:1") + void agentlessStartWaitsForApplicationProviderActivation() { + try { + FeatureFlaggingSystem.start(sharedCommunicationObjects()); + + assertTrue(FeatureFlaggingSystem.isAwaitingApplicationActivation()); + FeatureFlaggingGateway.activate(); + assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); + } finally { + FeatureFlaggingSystem.stop(); + } + + assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); + } + @Test @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "remote_config") @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "true") @@ -97,11 +119,17 @@ void explicitRemoteConfigUsesRemoteConfigService() { @Test @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "invalid") - void invalidConfigurationSourceUsesAgentlessDefault() { - assertInstanceOf( - AgentlessConfigurationSource.class, - FeatureFlaggingSystem.createConfigurationSourceService( - sharedCommunicationObjects(), Config.get())); + void invalidConfigurationSourceFailsClosed() { + final Config config = Config.get(); + assertFalse(config.isFeatureFlaggingProviderEnabled()); + assertNull(config.getFeatureFlaggingConfigurationSource()); + + try { + FeatureFlaggingSystem.start(sharedCommunicationObjects()); + assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); + } finally { + FeatureFlaggingSystem.stop(); + } } @Test @@ -116,24 +144,6 @@ void rejectsUnsupportedNormalizedConfigurationSource() { sharedCommunicationObjects(), config)); } - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "offline") - void offlineConfigurationSourceDoesNotStartNetworkSource() { - assertNull( - FeatureFlaggingSystem.createConfigurationSourceService( - sharedCommunicationObjects(), Config.get())); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "offline") - void startWithOfflineConfigurationSourceSkipsConfigService() { - try { - assertDoesNotThrow(() -> FeatureFlaggingSystem.start(sharedCommunicationObjects())); - } finally { - FeatureFlaggingSystem.stop(); - } - } - @Test void initializationFailureClosesConfigurationSourceAndExposureWriter() { ConfigurationSourceService configService = mock(ConfigurationSourceService.class); diff --git a/products/feature-flagging/feature-flagging-api/README.md b/products/feature-flagging/feature-flagging-api/README.md index 74bbccca2e2..bb4bf5e3275 100644 --- a/products/feature-flagging/feature-flagging-api/README.md +++ b/products/feature-flagging/feature-flagging-api/README.md @@ -97,7 +97,4 @@ OTEL_EXPORTER_OTLP_PROTOCOL=grpc and expects UFC under the JSON:API `data.attributes` response member. It is intended for supported commercial sites; use an explicit base URL elsewhere. Agentless responses do not have an SDK-imposed payload-size limit. - `remote_config` uses the existing Agent Remote - Configuration path. `offline` is reserved for startup-provided UFC bytes; - until those bytes are implemented, no network source starts and evaluations - use defaults. + `remote_config` uses the existing Agent Remote Configuration path. diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java index 654fb6e8f6c..6d55c54a5b3 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java @@ -67,6 +67,7 @@ public DDEvaluator(final Runnable configCallback) { @Override public boolean initialize( final long timeout, final TimeUnit unit, final EvaluationContext context) throws Exception { + FeatureFlaggingGateway.activate(); FeatureFlaggingGateway.addConfigListener(this); return initializationLatch.await(timeout, unit) || hasConfiguration(); } diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java index 3421fe3454e..cac510732db 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java @@ -23,6 +23,7 @@ import com.squareup.moshi.JsonWriter; import com.squareup.moshi.Moshi; import com.squareup.moshi.Types; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.api.featureflag.ufc.v1.Flag; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import dev.openfeature.sdk.ErrorCode; @@ -145,6 +146,22 @@ public void testInitializeTimesOutWithoutConfig() throws Exception { } } + @Test + public void testInitializeSignalsApplicationProviderActivation() throws Exception { + final FeatureFlaggingGateway.ActivationListener listener = + mock(FeatureFlaggingGateway.ActivationListener.class); + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + FeatureFlaggingGateway.addActivationListener(listener); + try { + evaluator.initialize(1, MILLISECONDS, mock(EvaluationContext.class)); + + verify(listener).activate(); + } finally { + evaluator.shutdown(); + FeatureFlaggingGateway.removeActivationListener(listener); + } + } + @Test public void testInitializeWaitsForNonNullConfig() throws Exception { final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java index 2704b4be341..c8f5625c855 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java @@ -11,11 +11,16 @@ public abstract class FeatureFlaggingGateway { public interface ConfigListener extends Consumer {} + public interface ActivationListener { + void activate(); + } + public interface ExposureListener extends Consumer {} public interface SpanEnrichmentListener extends Consumer {} private static final List CONFIG_LISTENERS = new CopyOnWriteArrayList<>(); + private static final List ACTIVATION_LISTENERS = new CopyOnWriteArrayList<>(); private static final List EXPOSURE_LISTENERS = new CopyOnWriteArrayList<>(); private static final List SPAN_ENRICHMENT_LISTENERS = new CopyOnWriteArrayList<>(); @@ -42,6 +47,19 @@ public static void dispatch(final ServerConfiguration config) { CONFIG_LISTENERS.forEach(listener -> listener.accept(config)); } + public static void addActivationListener(final ActivationListener listener) { + ACTIVATION_LISTENERS.add(listener); + } + + public static void removeActivationListener(final ActivationListener listener) { + ACTIVATION_LISTENERS.remove(listener); + } + + /** Signals that application code initialized the Datadog OpenFeature provider. */ + public static void activate() { + ACTIVATION_LISTENERS.forEach(ActivationListener::activate); + } + public static void addExposureListener(final ExposureListener listener) { EXPOSURE_LISTENERS.add(listener); } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java index 3af2ed5add8..daaaf8d7001 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java @@ -13,6 +13,7 @@ class FeatureFlaggingGatewayTest { private FeatureFlaggingGateway.ConfigListener configListener; + private FeatureFlaggingGateway.ActivationListener activationListener; private FeatureFlaggingGateway.ExposureListener exposureListener; private FeatureFlaggingGateway.SpanEnrichmentListener spanEnrichmentListener; private ServerConfiguration firstConfiguration; @@ -23,6 +24,7 @@ class FeatureFlaggingGatewayTest { @BeforeEach void setUp() { configListener = mock(FeatureFlaggingGateway.ConfigListener.class); + activationListener = mock(FeatureFlaggingGateway.ActivationListener.class); exposureListener = mock(FeatureFlaggingGateway.ExposureListener.class); spanEnrichmentListener = mock(FeatureFlaggingGateway.SpanEnrichmentListener.class); firstConfiguration = mock(ServerConfiguration.class); @@ -34,10 +36,21 @@ void setUp() { @AfterEach void tearDown() { FeatureFlaggingGateway.removeConfigListener(configListener); + FeatureFlaggingGateway.removeActivationListener(activationListener); FeatureFlaggingGateway.removeExposureListener(exposureListener); FeatureFlaggingGateway.removeSpanEnrichmentListener(spanEnrichmentListener); } + @Test + void testProviderActivationListener() { + FeatureFlaggingGateway.addActivationListener(activationListener); + + FeatureFlaggingGateway.activate(); + + verify(activationListener).activate(); + verifyNoMoreInteractions(activationListener); + } + @Test void testAttachingAConfigListener() { clearCurrentServerConfiguration(); diff --git a/products/feature-flagging/feature-flagging-config/build.gradle.kts b/products/feature-flagging/feature-flagging-config/build.gradle.kts index 14109d8dfd9..97dd3aa9f0d 100644 --- a/products/feature-flagging/feature-flagging-config/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-config/build.gradle.kts @@ -4,9 +4,8 @@ plugins { apply(from = "$rootDir/gradle/java.gradle") -description = "Feature flagging configuration keys (compile-time constants)" +description = "Feature flagging configuration keys and source resolution" -extra["excludedClassesCoverage"] = listOf( - // Constants-only holder — no executable logic to cover. - "datadog.trace.api.featureflag.config.FeatureFlaggingConfig", -) +dependencies { + testImplementation(libs.bundles.junit5) +} diff --git a/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java b/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java index 379f6470c5b..20936c3f00a 100644 --- a/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java +++ b/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java @@ -2,11 +2,22 @@ public class FeatureFlaggingConfig { - public static final String FLAGGING_PROVIDER_ENABLED = "experimental.flagging.provider.enabled"; + public static final String CONFIGURATION_SOURCE_AGENTLESS = "agentless"; + public static final String CONFIGURATION_SOURCE_REMOTE_CONFIG = "remote_config"; + + private static final Resolution DISABLED_RESOLUTION = new Resolution(false, null); + private static final Resolution AGENTLESS_CONFIGURATION = + new Resolution(true, CONFIGURATION_SOURCE_AGENTLESS); + private static final Resolution REMOTE_CONFIG_CONFIGURATION = + new Resolution(true, CONFIGURATION_SOURCE_REMOTE_CONFIG); + + public static final String FEATURE_FLAGS_ENABLED = "feature.flags.enabled"; + public static final String EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED = + "experimental.flagging.provider.enabled"; /** * Opt-in gate for APM span enrichment with feature-flag evaluation metadata. DISTINCT from {@link - * #FLAGGING_PROVIDER_ENABLED} and OFF by default — enabling the provider does not enable span + * #FEATURE_FLAGS_ENABLED} and OFF by default — enabling the provider does not enable span * enrichment. */ public static final String EXPERIMENTAL_SPAN_ENRICHMENT_ENABLED = @@ -21,5 +32,55 @@ public class FeatureFlaggingConfig { public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS = "feature.flags.configuration.source.agentless.request.timeout.seconds"; + public static Resolution resolveConfiguration( + final Boolean providerEnabled, + final String explicitSource, + final Boolean legacyProviderEnabled) { + if (Boolean.FALSE.equals(providerEnabled)) { + return DISABLED_RESOLUTION; + } + if (explicitSource != null && !explicitSource.trim().isEmpty()) { + final String source = explicitSource.trim(); + if (CONFIGURATION_SOURCE_AGENTLESS.equalsIgnoreCase(source)) { + return AGENTLESS_CONFIGURATION; + } + if (CONFIGURATION_SOURCE_REMOTE_CONFIG.equalsIgnoreCase(source)) { + return REMOTE_CONFIG_CONFIGURATION; + } + return DISABLED_RESOLUTION; + } + if (legacyProviderEnabled != null) { + return legacyProviderEnabled ? REMOTE_CONFIG_CONFIGURATION : DISABLED_RESOLUTION; + } + return AGENTLESS_CONFIGURATION; + } + + public static boolean isSupportedConfigurationSource(final String source) { + if (source == null || source.trim().isEmpty()) { + return true; + } + final String normalized = source.trim(); + return CONFIGURATION_SOURCE_AGENTLESS.equalsIgnoreCase(normalized) + || CONFIGURATION_SOURCE_REMOTE_CONFIG.equalsIgnoreCase(normalized); + } + + public static final class Resolution { + private final boolean enabled; + private final String source; + + private Resolution(final boolean enabled, final String source) { + this.enabled = enabled; + this.source = source; + } + + public boolean isEnabled() { + return enabled; + } + + public String getSource() { + return source; + } + } + private FeatureFlaggingConfig() {} } diff --git a/products/feature-flagging/feature-flagging-config/src/test/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfigTest.java b/products/feature-flagging/feature-flagging-config/src/test/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfigTest.java new file mode 100644 index 00000000000..37f3971213f --- /dev/null +++ b/products/feature-flagging/feature-flagging-config/src/test/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfigTest.java @@ -0,0 +1,53 @@ +package datadog.trace.api.featureflag.config; + +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.isSupportedConfigurationSource; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.resolveConfiguration; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class FeatureFlaggingConfigTest { + + @Test + void appliesConfigurationPrecedence() { + assertResolution(true, CONFIGURATION_SOURCE_AGENTLESS, null, null, null); + assertResolution(true, CONFIGURATION_SOURCE_AGENTLESS, null, " ", null); + assertResolution(true, CONFIGURATION_SOURCE_REMOTE_CONFIG, null, null, true); + assertResolution(false, null, null, null, false); + assertResolution(true, CONFIGURATION_SOURCE_AGENTLESS, null, "agentless", true); + assertResolution(true, CONFIGURATION_SOURCE_REMOTE_CONFIG, null, " remote_CONFIG ", false); + assertResolution(false, null, false, "agentless", true); + assertResolution(false, null, null, "invalid", null); + } + + @Test + void recognizesSupportedExplicitSources() { + assertTrue(isSupportedConfigurationSource(null)); + assertTrue(isSupportedConfigurationSource(" ")); + assertTrue(isSupportedConfigurationSource("agentless")); + assertTrue(isSupportedConfigurationSource(" REMOTE_CONFIG ")); + assertFalse(isSupportedConfigurationSource("invalid")); + } + + private static void assertResolution( + final boolean enabled, + final String source, + final Boolean providerEnabled, + final String explicitSource, + final Boolean legacyProviderEnabled) { + final FeatureFlaggingConfig.Resolution resolution = + resolveConfiguration(providerEnabled, explicitSource, legacyProviderEnabled); + + assertEquals(enabled, resolution.isEnabled()); + if (source == null) { + assertNull(resolution.getSource()); + } else { + assertEquals(source, resolution.getSource()); + } + } +}