diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index df0fca12659..ec1b29bb8ff 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -350,7 +350,7 @@ build: script: - if [ $CI_PIPELINE_SOURCE == "schedule" ] ; then ./gradlew resolveAndLockAll --write-locks $GRADLE_ARGS; fi - ./gradlew --version - - ./gradlew clean :dd-java-agent:shadowJar :dd-java-agent:check :dd-trace-api:jar :dd-trace-ot:shadowJar -PskipTests -x spotlessCheck $GRADLE_ARGS + - ./gradlew clean :dd-java-agent:shadowJar :dd-java-agent:check :dd-trace-api:jar :dd-trace-ot:shadowJar :products:feature-flagging:feature-flagging-api:jar -PskipTests -x spotlessCheck $GRADLE_ARGS - echo UPSTREAM_TRACER_VERSION=$(java -jar workspace/dd-java-agent/build/libs/*.jar) >> upstream.env - echo "BUILD_JOB_NAME=$CI_JOB_NAME" >> build.env - echo "BUILD_JOB_ID=$CI_JOB_ID" >> build.env @@ -360,6 +360,7 @@ build: - 'workspace/dd-java-agent/build/libs/*.jar' - 'workspace/dd-trace-api/build/libs/*.jar' - 'workspace/dd-trace-ot/build/libs/*.jar' + - 'workspace/products/feature-flagging/feature-flagging-api/build/libs/*.jar' - 'upstream.env' - '.gradle/daemon/*/*.out.log' reports: @@ -425,9 +426,11 @@ publish-artifacts-to-s3: - aws s3 cp workspace/dd-java-agent/build/libs/dd-java-agent-${VERSION}.jar s3://dd-trace-java-builds/${CI_COMMIT_REF_NAME}/dd-java-agent.jar - aws s3 cp workspace/dd-trace-api/build/libs/dd-trace-api-${VERSION}.jar s3://dd-trace-java-builds/${CI_COMMIT_REF_NAME}/dd-trace-api.jar - aws s3 cp workspace/dd-trace-ot/build/libs/dd-trace-ot-${VERSION}.jar s3://dd-trace-java-builds/${CI_COMMIT_REF_NAME}/dd-trace-ot.jar + - aws s3 cp workspace/products/feature-flagging/feature-flagging-api/build/libs/dd-openfeature-${VERSION}.jar s3://dd-trace-java-builds/${CI_COMMIT_REF_NAME}/dd-openfeature.jar - aws s3 cp workspace/dd-java-agent/build/libs/dd-java-agent-${VERSION}.jar s3://dd-trace-java-builds/${CI_PIPELINE_ID}/dd-java-agent.jar - aws s3 cp workspace/dd-trace-api/build/libs/dd-trace-api-${VERSION}.jar s3://dd-trace-java-builds/${CI_PIPELINE_ID}/dd-trace-api.jar - aws s3 cp workspace/dd-trace-ot/build/libs/dd-trace-ot-${VERSION}.jar s3://dd-trace-java-builds/${CI_PIPELINE_ID}/dd-trace-ot.jar + - aws s3 cp workspace/products/feature-flagging/feature-flagging-api/build/libs/dd-openfeature-${VERSION}.jar s3://dd-trace-java-builds/${CI_PIPELINE_ID}/dd-openfeature.jar - | cat << EOF > links.json { @@ -437,6 +440,12 @@ publish-artifacts-to-s3: "label": "Public Link to dd-java-agent.jar", "url": "https://s3.us-east-1.amazonaws.com/dd-trace-java-builds/${CI_PIPELINE_ID}/dd-java-agent.jar" } + }, + { + "external_link": { + "label": "Public Link to dd-openfeature.jar", + "url": "https://s3.us-east-1.amazonaws.com/dd-trace-java-builds/${CI_PIPELINE_ID}/dd-openfeature.jar" + } } ] } 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 5b88389aefc..4a7c31a9826 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 @@ -160,6 +160,8 @@ void testFeatureFlagSystemInitialization() { FeatureFlaggingSystem.start(sharedCommunicationObjects); FeatureFlaggingSystem.start(sharedCommunicationObjects); + FeatureFlaggingGateway.activate(); + FeatureFlaggingGateway.activate(); verify(poller).addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); verify(poller).addListener(eq(Product.FFE_FLAGS), any(ConfigurationDeserializer.class), any()); diff --git a/products/feature-flagging/feature-flagging-api/README.md b/products/feature-flagging/feature-flagging-api/README.md index 74bbccca2e2..e41d40d8525 100644 --- a/products/feature-flagging/feature-flagging-api/README.md +++ b/products/feature-flagging/feature-flagging-api/README.md @@ -16,6 +16,9 @@ Published as `com.datadoghq:dd-openfeature` on Maven Central. The OpenFeature SDK (`dev.openfeature:sdk`) is included as a transitive dependency. +`dd-openfeature` 1.66.0 and later requires `dd-java-agent` 1.66.0 or later because it uses +the process-wide Feature Flagging configuration snapshot API. + ### Evaluation metrics (optional) To enable evaluation metrics (`feature_flag.evaluations` counter), enable the Datadog Java agent's @@ -55,6 +58,40 @@ boolean enabled = client.getBooleanValue("my-feature", false, new MutableContext("user-123")); ``` +### Inspecting evaluations without telemetry + +Use OpenFeature domains when one client should perform normal live evaluations and another should +only inspect the result. Register a separate `Provider` for each domain and disable telemetry on the +provider assigned to the inspection domain: + +```java +OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + +api.setProviderAndWait("live", new Provider()); +api.setProviderAndWait( + "peek", + new Provider(new Provider.Options().telemetryEnabled(false))); + +Client checkoutClient = api.getClient("live"); +Client analyticsClient = api.getClient("peek"); + +EvaluationContext checkoutContext = new MutableContext("session-abc"); +EvaluationContext analyticsContext = new MutableContext("user-123"); + +// Evaluates normally and emits the configured Datadog telemetry. +boolean checkoutEnabled = checkoutClient.getBooleanValue( + "my-feature", false, checkoutContext); + +// Evaluates against the shared current configuration without emitting Datadog telemetry. +boolean analyticsEnabled = analyticsClient.getBooleanValue( + "my-feature", false, analyticsContext); +``` + +`telemetryEnabled(false)` suppresses exposures, EVP flag-evaluation events, OpenTelemetry +evaluation metrics, and APM span enrichment for that provider. It does not disable evaluation or +change the configuration used to resolve flags. All provider domains read the same process-wide +configuration snapshot and share one configuration request path. + ## Evaluation metrics When `DD_METRICS_OTEL_ENABLED=true` and the OpenTelemetry API is on the classpath, the provider 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 a9f51b4a660..e562f6c6842 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 @@ -3,6 +3,7 @@ import static java.util.Arrays.asList; import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.FeatureFlaggingGateway.ConfigSnapshot; import datadog.trace.api.featureflag.exposure.ExposureEvent; import datadog.trace.api.featureflag.exposure.Subject; import datadog.trace.api.featureflag.ufc.v1.Allocation; @@ -43,7 +44,6 @@ import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; @@ -111,11 +111,17 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener { private static final boolean SPAN_ENRICHMENT_ENABLED = SpanEnrichmentGate.isEnabled(); private final Runnable configCallback; - private final AtomicReference configuration = new AtomicReference<>(); + private final boolean telemetryEnabled; private final CountDownLatch initializationLatch = new CountDownLatch(1); + private long lastConfigVersion; - public DDEvaluator(final Runnable configCallback) { + DDEvaluator(final Runnable configCallback) { + this(configCallback, true); + } + + public DDEvaluator(final Runnable configCallback, final boolean telemetryEnabled) { this.configCallback = configCallback; + this.telemetryEnabled = telemetryEnabled; } @Override @@ -128,7 +134,7 @@ public boolean initialize( @Override public boolean hasConfiguration() { - return configuration.get() != null; + return FeatureFlaggingGateway.getConfigSnapshot().getConfig() != null; } @Override @@ -137,8 +143,15 @@ public void shutdown() { } @Override - public void accept(final ServerConfiguration config) { - configuration.set(config); + public synchronized void accept(final ServerConfiguration ignored) { + // Listener callbacks are notifications only. Always read the process-wide snapshot so a stale + // register-and-replay callback cannot restore an older configuration in this evaluator. + final ConfigSnapshot snapshot = FeatureFlaggingGateway.getConfigSnapshot(); + if (snapshot.getVersion() <= lastConfigVersion) { + return; + } + lastConfigVersion = snapshot.getVersion(); + final ServerConfiguration config = snapshot.getConfig(); if (config != null) { initializationLatch.countDown(); configCallback.run(); @@ -156,7 +169,8 @@ public ProviderEvaluation evaluate( // Snapshot the config once and thread observeFullEvaluationData through every // ProviderEvaluation returned, so the hook's consent decision is pinned to this evaluation's // config and cannot drift on a concurrent Remote Config swap. - final ServerConfiguration config = configuration.get(); + final ConfigSnapshot snapshot = FeatureFlaggingGateway.getConfigSnapshot(); + final ServerConfiguration config = snapshot.getConfig(); // Boolean.TRUE.equals covers both null (privacy-preserving default) and Boolean.FALSE without // an NPE — the field is boxed so a malformed UFC message doesn't abort the whole parse. final boolean observeFullEvaluationData = @@ -495,7 +509,7 @@ private static String getMD5Hash(final String input) { } } - private static ProviderEvaluation resolveVariant( + private ProviderEvaluation resolveVariant( final Class target, final String key, final T defaultValue, @@ -552,11 +566,12 @@ private static ProviderEvaluation resolveVariant( .addLong("__dd_eval_timestamp_ms", evalTimestampMs) .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData); // Surface the UFC split's serial id and the allocation's doLog flag for APM span enrichment — - // only when span enrichment is on, so a provider without enrichment pays nothing extra. + // only when telemetry and span enrichment are on, so a provider without enrichment pays + // nothing extra. // __dd_split_serial_id is omitted when the split carries no serial id; __dd_do_log is always // present (when enrichment is on) so the span-enrichment hook can decide whether to record the // subject. - if (SPAN_ENRICHMENT_ENABLED) { + if (telemetryEnabled && SPAN_ENRICHMENT_ENABLED) { if (split.serialId != null) { metadataBuilder.addInteger(METADATA_SPLIT_SERIAL_ID, split.serialId); } @@ -575,7 +590,7 @@ private static ProviderEvaluation resolveVariant( .flagMetadata(metadataBuilder.build()) .build(); final boolean doLog = allocation.doLog != null && allocation.doLog; - if (doLog) { + if (telemetryEnabled && doLog) { dispatchExposure(key, result, context); } return result; diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java index 17009d9933a..26c67eaa81f 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java @@ -1,5 +1,6 @@ package datadog.trace.api.openfeature; +import static java.util.Objects.requireNonNull; import static java.util.concurrent.TimeUnit.SECONDS; import de.thetaphi.forbiddenapis.SuppressForbidden; @@ -29,10 +30,11 @@ public class Provider extends EventProvider implements Metadata { private static final Logger log = LoggerFactory.getLogger(Provider.class); static final String METADATA = "datadog-openfeature-provider"; private static final String EVALUATOR_IMPL = "datadog.trace.api.openfeature.DDEvaluator"; - - private static final Options DEFAULT_OPTIONS = new Options().initTimeout(30, SECONDS); + private static final long DEFAULT_INIT_TIMEOUT = 30; private volatile Evaluator evaluator; - private final Options options; + private final long initTimeout; + private final TimeUnit initTimeoutUnit; + private final boolean telemetryEnabled; private final AtomicReference initializationState = new AtomicReference<>(InitializationState.NOT_STARTED); private final FlagEvalMetrics flagEvalMetrics; @@ -44,7 +46,7 @@ public class Provider extends EventProvider implements Metadata { private final List providerHooks; public Provider() { - this(DEFAULT_OPTIONS, null); + this(new Options(), null); } public Provider(final Options options) { @@ -56,54 +58,63 @@ public Provider(final Options options) { } /** - * @param spanEnrichmentEnabledOverride when non-null, forces the span-enrichment gate (test - * seam); when null, the gate is read via {@link SpanEnrichmentGate}. + * @param spanEnrichmentEnabledOverride when non-null, replaces the {@link SpanEnrichmentGate} + * reading (test seam); when null, the gate is read via {@link SpanEnrichmentGate}. Either way + * span enrichment stays off when {@code options.isTelemetryEnabled()} is false. */ Provider( final Options options, final Evaluator evaluator, final Boolean spanEnrichmentEnabledOverride) { - this.options = options; + requireNonNull(options, "options"); + this.initTimeout = options.getTimeout(); + this.initTimeoutUnit = options.getUnit(); + this.telemetryEnabled = options.isTelemetryEnabled(); this.evaluator = evaluator; + FlagEvalMetrics metrics = null; - FlagEvalMetricsHook hook = null; - try { - metrics = new FlagEvalMetrics(); - hook = new FlagEvalMetricsHook(metrics); - } catch (LinkageError | Exception e) { - // This outer catch fires when the metrics helper itself can't load (OTel API absent). - log.warn("Evaluation metrics unavailable — OTel API classes not on classpath", e); - } - this.flagEvalMetrics = metrics; - this.flagEvalMetricsHook = hook; + FlagEvalMetricsHook metricsHook = null; + SpanEnrichmentHook enrichmentHook = null; + final List hooks = new ArrayList<>(3); + if (telemetryEnabled) { + try { + metrics = new FlagEvalMetrics(); + metricsHook = new FlagEvalMetricsHook(metrics); + hooks.add(metricsHook); + } catch (LinkageError | Exception e) { + // This outer catch fires when the metrics helper itself can't load (OTel API absent). + log.warn("Evaluation metrics unavailable — OTel API classes not on classpath", e); + } - // Span enrichment is wired ONLY when the gate is on — off means no capture hook and no idle - // per-evaluation overhead. - final boolean spanEnrichmentEnabled = - spanEnrichmentEnabledOverride != null - ? spanEnrichmentEnabledOverride - : SpanEnrichmentGate.isEnabled(); - this.spanEnrichmentHook = spanEnrichmentEnabled ? new SpanEnrichmentHook() : null; + // EVP flagevaluation hook: registered when provider telemetry is enabled; no-op when the + // writer is absent (killswitch off). The writer is resolved lazily on each call. + try { + final Hook flagEvalLoggingHook = buildFlagEvalLoggingHook(); + if (flagEvalLoggingHook != null) { + hooks.add(flagEvalLoggingHook); + } + } catch (LinkageError | Exception e) { + // Keep older bootstrap/API combinations working: EVP recording is best-effort. + } - // Precompute the immutable hook list once so getProviderHooks() (called on every evaluation) - // allocates nothing, including when the gate is off. - final List hooks = new ArrayList<>(3); - if (flagEvalMetricsHook != null) { - hooks.add(flagEvalMetricsHook); - } - // EVP flagevaluation hook: always registered; no-op when writer is absent (killswitch off). - // Writer is resolved lazily from FeatureFlaggingGateway.getFlagEvalWriter() on each call. - try { - final Hook flagEvalLoggingHook = buildFlagEvalLoggingHook(); - if (flagEvalLoggingHook != null) { - hooks.add(flagEvalLoggingHook); + // Span enrichment is wired ONLY when the gate is on — off means no capture hook and no idle + // per-evaluation overhead. + final boolean spanEnrichmentEnabled = + spanEnrichmentEnabledOverride != null + ? spanEnrichmentEnabledOverride + : SpanEnrichmentGate.isEnabled(); + enrichmentHook = spanEnrichmentEnabled ? new SpanEnrichmentHook() : null; + if (enrichmentHook != null) { + hooks.add(enrichmentHook); } - } catch (LinkageError | Exception e) { - // Keep older bootstrap/API combinations working: EVP recording is best-effort. - } - if (spanEnrichmentHook != null) { - hooks.add(spanEnrichmentHook); } + + this.flagEvalMetrics = metrics; + this.flagEvalMetricsHook = metricsHook; + this.spanEnrichmentHook = enrichmentHook; + + // Precompute the immutable hook list once so getProviderHooks() (called on every evaluation) + // allocates nothing, including when telemetry is disabled. this.providerHooks = hooks.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(hooks); @@ -121,7 +132,7 @@ public void initialize(final EvaluationContext context) throws Exception { initializationState.set(InitializationState.INITIALIZING); try { evaluator = buildEvaluator(); - if (!evaluator.initialize(options.getTimeout(), options.getUnit(), context)) { + if (!evaluator.initialize(initTimeout, initTimeoutUnit, context)) { if (markInitialConfigReceivedReady()) { return; } @@ -137,6 +148,13 @@ public void initialize(final EvaluationContext context) throws Exception { } catch (final OpenFeatureError e) { markInitializationError(); throw e; + } catch (final LinkageError e) { + markInitializationError(); + throw new FatalError( + "Failed to initialize provider: a required Datadog agent class or method is missing." + + " The Datadog Java agent is likely absent, or older than this dd-openfeature" + + " release.", + e); } catch (final Throwable e) { markInitializationError(); throw new FatalError("Failed to initialize provider, is the tracer configured?", e); @@ -217,8 +235,8 @@ private Evaluator buildEvaluator() throws Exception { return evaluator; } final Class evaluatorClass = loadEvaluatorClass(); - final Constructor ctor = evaluatorClass.getConstructor(Runnable.class); - return (Evaluator) ctor.newInstance((Runnable) this::onConfigurationChange); + final Constructor ctor = evaluatorClass.getConstructor(Runnable.class, boolean.class); + return (Evaluator) ctor.newInstance((Runnable) this::onConfigurationChange, telemetryEnabled); } @Override @@ -303,8 +321,9 @@ private enum InitializationState { public static class Options { - private long timeout; - private TimeUnit unit; + private long timeout = DEFAULT_INIT_TIMEOUT; + private TimeUnit unit = SECONDS; + private boolean telemetryEnabled = true; public Options initTimeout(final long timeout, final TimeUnit unit) { this.timeout = timeout; @@ -319,5 +338,26 @@ public long getTimeout() { public TimeUnit getUnit() { return unit; } + + /** + * Enables or disables Datadog telemetry produced by this provider. + * + *

When disabled, evaluations still use the current Datadog configuration and return normal + * results, but the provider emits no exposures, EVP flag-evaluation events, OpenTelemetry + * evaluation metrics, or APM span enrichment. This is useful with OpenFeature domains: bind a + * telemetry-enabled provider to a live domain and a telemetry-disabled provider to a domain + * used only to inspect evaluations. + * + * @param telemetryEnabled whether this provider should produce Datadog telemetry + * @return these options + */ + public Options telemetryEnabled(final boolean telemetryEnabled) { + this.telemetryEnabled = telemetryEnabled; + return this; + } + + public boolean isTelemetryEnabled() { + return telemetryEnabled; + } } } 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 aa4fe133c0d..fe357bdfbbd 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 @@ -13,6 +13,7 @@ import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.Matchers.hasEntry; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; @@ -26,6 +27,7 @@ import com.squareup.moshi.Moshi; import com.squareup.moshi.Types; import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.exposure.ExposureEvent; import datadog.trace.api.featureflag.ufc.v1.Allocation; import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; import datadog.trace.api.featureflag.ufc.v1.ConditionOperator; @@ -56,11 +58,15 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -86,6 +92,12 @@ public class DDEvaluatorTest { ThreadLocal.withInitial(HashMap::new); private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; + @BeforeEach + @AfterEach + void clearCurrentConfiguration() { + FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + } + @Test public void testInitializeSignalsApplicationProviderActivation() throws Exception { final FeatureFlaggingGateway.ActivationListener listener = @@ -173,7 +185,7 @@ public void testEvaluateNoConfig() { public void testInitializeTimesOutWithoutConfig() throws Exception { final Runnable configCallback = mock(Runnable.class); final DDEvaluator evaluator = new DDEvaluator(configCallback); - evaluator.accept(null); + FeatureFlaggingGateway.dispatch((ServerConfiguration) null); try { assertThat( evaluator.initialize(10, MILLISECONDS, mock(EvaluationContext.class)), equalTo(false)); @@ -191,10 +203,10 @@ public void testInitializeWaitsForNonNullConfig() throws Exception { final Future initialized = executor.submit(() -> evaluator.initialize(1, SECONDS, mock(EvaluationContext.class))); - evaluator.accept(null); + FeatureFlaggingGateway.dispatch((ServerConfiguration) null); assertThat(initialized.isDone(), equalTo(false)); - evaluator.accept(mock(ServerConfiguration.class)); + FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); assertThat(initialized.get(1, SECONDS), equalTo(true)); } finally { executor.shutdownNow(); @@ -202,10 +214,109 @@ public void testInitializeWaitsForNonNullConfig() throws Exception { } } + @Test + public void testConcurrentRegistrationCannotRestoreStaleConfiguration() throws Exception { + final ServerConfiguration firstConfiguration = + new ServerConfiguration("first", "", false, null, emptyMap()); + final ServerConfiguration secondConfiguration = + new ServerConfiguration("second", "", true, null, emptyMap()); + final CountDownLatch staleReplayStarted = new CountDownLatch(1); + final CountDownLatch allowStaleReplay = new CountDownLatch(1); + final DDEvaluator evaluator = + new DDEvaluator(mock(Runnable.class)) { + @Override + public void accept(final ServerConfiguration configuration) { + if (configuration == firstConfiguration) { + staleReplayStarted.countDown(); + try { + if (!allowStaleReplay.await(5, SECONDS)) { + throw new AssertionError( + "Timed out waiting to resume stale configuration replay"); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError( + "Interrupted while waiting to replay stale configuration", e); + } + } + super.accept(configuration); + } + }; + final ExecutorService executor = Executors.newSingleThreadExecutor(); + + FeatureFlaggingGateway.dispatch(firstConfiguration); + try { + final Future registration = + executor.submit(() -> FeatureFlaggingGateway.addConfigListener(evaluator)); + + assertThat(staleReplayStarted.await(5, SECONDS), equalTo(true)); + FeatureFlaggingGateway.dispatch(secondConfiguration); + allowStaleReplay.countDown(); + registration.get(5, SECONDS); + + final ProviderEvaluation details = + evaluator.evaluate(Integer.class, "missing", 23, mock(EvaluationContext.class)); + + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(true)); + } finally { + allowStaleReplay.countDown(); + evaluator.shutdown(); + FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + executor.shutdownNow(); + } + } + + @Test + public void testEvaluatorsReadSameProcessWideConfiguration() { + final DDEvaluator firstEvaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator secondEvaluator = new DDEvaluator(mock(Runnable.class)); + + FeatureFlaggingGateway.dispatch(new ServerConfiguration("first", "", false, null, emptyMap())); + assertThat(observeFullEvaluationData(firstEvaluator), equalTo(false)); + assertThat(observeFullEvaluationData(secondEvaluator), equalTo(false)); + + FeatureFlaggingGateway.dispatch(new ServerConfiguration("second", "", true, null, emptyMap())); + assertThat(observeFullEvaluationData(firstEvaluator), equalTo(true)); + assertThat(observeFullEvaluationData(secondEvaluator), equalTo(true)); + } + + @Test + public void testUnavailableConfigurationIsVisibleToAllEvaluators() { + final DDEvaluator firstEvaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator secondEvaluator = new DDEvaluator(mock(Runnable.class)); + final EvaluationContext context = mock(EvaluationContext.class); + + FeatureFlaggingGateway.dispatch( + new ServerConfiguration("available", "", false, null, emptyMap())); + assertThat( + firstEvaluator.evaluate(Integer.class, "missing", 23, context).getErrorCode(), + equalTo(ErrorCode.FLAG_NOT_FOUND)); + assertThat( + secondEvaluator.evaluate(Integer.class, "missing", 23, context).getErrorCode(), + equalTo(ErrorCode.FLAG_NOT_FOUND)); + + FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + assertThat( + firstEvaluator.evaluate(Integer.class, "missing", 23, context).getErrorCode(), + equalTo(ErrorCode.PROVIDER_NOT_READY)); + assertThat( + secondEvaluator.evaluate(Integer.class, "missing", 23, context).getErrorCode(), + equalTo(ErrorCode.PROVIDER_NOT_READY)); + } + + private static Boolean observeFullEvaluationData(final DDEvaluator evaluator) { + return evaluator + .evaluate(Integer.class, "missing", 23, mock(EvaluationContext.class)) + .getFlagMetadata() + .getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA); + } + @Test public void testEvaluateNoContext() { final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(mock(ServerConfiguration.class)); + FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); final ProviderEvaluation details = evaluator.evaluate(Integer.class, "test", 23, null); assertThat(details.getValue(), equalTo(23)); assertThat(details.getReason(), equalTo(ERROR.name())); @@ -218,7 +329,7 @@ public void testNoAllocations() { flags.put("null-allocation", new Flag("target", true, null, null, null)); flags.put("empty-allocation", new Flag("target", true, null, null, emptyList())); final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", false, null, flags)); + FeatureFlaggingGateway.dispatch(new ServerConfiguration("", "", false, null, flags)); final EvaluationContext ctx = new MutableContext("target").setTargetingKey("allocation"); @@ -252,7 +363,7 @@ public void testEvaluateUnsignedShardRange() { "target", new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation))); final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", false, null, flags)); + FeatureFlaggingGateway.dispatch(new ServerConfiguration("", "", false, null, flags)); final EvaluationContext context = new MutableContext("target").setTargetingKey("high-shard-user"); @@ -298,6 +409,30 @@ public void observeFullEvaluationDataStampedFalseOnResolvedVariant() { equalTo(false)); } + @Test + public void telemetryDisabledSuppressesExposures() { + final AtomicReference exposure = new AtomicReference<>(); + final FeatureFlaggingGateway.ExposureListener listener = exposure::set; + FeatureFlaggingGateway.addExposureListener(listener); + try { + evaluateMatchingFlag(false, true, false); + assertNull(exposure.get()); + + evaluateMatchingFlag(false, true, true); + assertNotNull(exposure.get()); + } finally { + FeatureFlaggingGateway.removeExposureListener(listener); + } + } + + @Test + public void telemetryDisabledOmitsSpanEnrichmentMetadata() { + final ProviderEvaluation details = evaluateMatchingFlag(false, true, false, 17); + + assertNull(details.getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); + assertNull(details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_DO_LOG)); + } + // -- DISABLED path: flag.enabled=false -- @Test @@ -351,7 +486,7 @@ public void observeFullEvaluationDataStampedOnFlagNotFoundError() { // Was previously named "…OnSuccess" but actually exercises the error() helper's stamp via // FLAG_NOT_FOUND — kept for that stamp site, correctly named. final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", true, null, new HashMap<>())); + FeatureFlaggingGateway.dispatch(new ServerConfiguration("", "", true, null, new HashMap<>())); final EvaluationContext ctx = new MutableContext("target").setTargetingKey("k"); final ProviderEvaluation details = @@ -382,7 +517,7 @@ public void observeFullEvaluationDataNullConfigFieldTreatedAsFalse() { final Map flags = new HashMap<>(); flags.put("target", new Flag("target", true, ValueType.INTEGER, emptyMap(), emptyList())); final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", null, null, flags)); + FeatureFlaggingGateway.dispatch(new ServerConfiguration("", "", null, null, flags)); final EvaluationContext ctx = new MutableContext("target").setTargetingKey("k"); final ProviderEvaluation details = evaluator.evaluate(Integer.class, "target", 23, ctx); @@ -399,14 +534,35 @@ public void observeFullEvaluationDataNullConfigFieldTreatedAsFalse() { // and a single "on" variant whose value maps to the requested Integer type. private static ProviderEvaluation evaluateMatchingFlag( final boolean observeFullEvaluationData) { + return evaluateMatchingFlag(observeFullEvaluationData, false); + } + + private static ProviderEvaluation evaluateMatchingFlag( + final boolean observeFullEvaluationData, final boolean doLog) { + return evaluateMatchingFlag(observeFullEvaluationData, doLog, true); + } + + private static ProviderEvaluation evaluateMatchingFlag( + final boolean observeFullEvaluationData, + final boolean doLog, + final boolean telemetryEnabled) { + return evaluateMatchingFlag(observeFullEvaluationData, doLog, telemetryEnabled, null); + } + + private static ProviderEvaluation evaluateMatchingFlag( + final boolean observeFullEvaluationData, + final boolean doLog, + final boolean telemetryEnabled, + final Integer serialId) { final Map variations = new HashMap<>(); variations.put("on", new Variant("on", 1)); - final Split split = new Split(emptyList(), "on", emptyMap(), null); + final Split split = new Split(emptyList(), "on", emptyMap(), serialId); final Allocation allocation = - new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.FALSE); + new Allocation("alloc-1", null, null, null, singletonList(split), doLog); return evaluateFlag( new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation)), - observeFullEvaluationData); + observeFullEvaluationData, + telemetryEnabled); } private static ProviderEvaluation evaluateDisabledFlag( @@ -428,10 +584,16 @@ private static ProviderEvaluation evaluateWithEmptySplits( private static ProviderEvaluation evaluateFlag( final Flag flag, final boolean observeFullEvaluationData) { + return evaluateFlag(flag, observeFullEvaluationData, true); + } + + private static ProviderEvaluation evaluateFlag( + final Flag flag, final boolean observeFullEvaluationData, final boolean telemetryEnabled) { final Map flags = new HashMap<>(); flags.put("target", flag); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class), telemetryEnabled); + FeatureFlaggingGateway.dispatch( + new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); final EvaluationContext ctx = new MutableContext("target").setTargetingKey("user-1"); return evaluator.evaluate(Integer.class, "target", 23, ctx); @@ -492,7 +654,8 @@ private static ProviderEvaluation evaluateWithNumericRuleOnId( "num-rule", new Flag("num-rule", true, ValueType.INTEGER, emptyMap(), singletonList(allocation))); final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); + FeatureFlaggingGateway.dispatch( + new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); final EvaluationContext ctx = new MutableContext(targetingKey); return evaluator.evaluate(Integer.class, "num-rule", 23, ctx); @@ -607,7 +770,7 @@ public void testEvaluateSemverCondition( final Map flags = new HashMap<>(); flags.put("test-flag", semverFlag(operator, comparand)); final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", null, null, flags)); + FeatureFlaggingGateway.dispatch(new ServerConfiguration("", "", null, null, flags)); final ProviderEvaluation details = evaluator.evaluate(Boolean.class, "test-flag", false, semverContext(attribute)); @@ -626,7 +789,7 @@ public void testEvaluateSemverConditionMissingAttribute() { final Map flags = new HashMap<>(); flags.put("test-flag", semverFlag(ConditionOperator.SEMVER_EQ, "1.2.3")); final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", null, null, flags)); + FeatureFlaggingGateway.dispatch(new ServerConfiguration("", "", null, null, flags)); final ProviderEvaluation details = evaluator.evaluate(Boolean.class, "test-flag", false, semverContext(null)); @@ -645,7 +808,7 @@ public void testEvaluateSemverConditionInvalidComparandReturnsParseError() { final ServerConfiguration config = new ServerConfiguration("", "", null, null, flags); config.invalidFlags = invalidFlags; final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(config); + FeatureFlaggingGateway.dispatch(config); final ProviderEvaluation details = evaluator.evaluate(Boolean.class, "invalid-semver", false, semverContext("1.2.3")); @@ -663,7 +826,7 @@ public void testEvaluateMalformedFlagReturnsParseError() { final ServerConfiguration config = new ServerConfiguration("", "", null, null, flags); config.invalidFlags = invalidFlags; final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(config); + FeatureFlaggingGateway.dispatch(config); final ProviderEvaluation details = evaluator.evaluate(Boolean.class, "malformed-flag", false, new MutableContext()); @@ -875,7 +1038,7 @@ public void testCanonicalFixturesArePresent() throws IOException { @ParameterizedTest(name = "{0}") public void testEvaluateCanonicalFixture(final FixtureCase testCase) throws IOException { final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(loadCanonicalConfiguration()); + FeatureFlaggingGateway.dispatch(loadCanonicalConfiguration()); final Class targetType = targetType(testCase.variationType); final Object defaultValue = mapFixtureValue(targetType, testCase.defaultValue); diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java index d7adf645e74..d83920f9787 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java @@ -1,11 +1,16 @@ package datadog.trace.api.openfeature; import static datadog.trace.api.openfeature.Provider.METADATA; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static java.util.concurrent.TimeUnit.SECONDS; import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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; @@ -17,9 +22,15 @@ import static org.mockito.Mockito.when; import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.exposure.ExposureEvent; import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; +import datadog.trace.api.featureflag.ufc.v1.Allocation; +import datadog.trace.api.featureflag.ufc.v1.Flag; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import datadog.trace.api.featureflag.ufc.v1.Split; +import datadog.trace.api.featureflag.ufc.v1.ValueType; +import datadog.trace.api.featureflag.ufc.v1.Variant; import datadog.trace.api.openfeature.Provider.Options; import dev.openfeature.sdk.Client; import dev.openfeature.sdk.ErrorCode; @@ -38,7 +49,8 @@ import dev.openfeature.sdk.exceptions.FatalError; import dev.openfeature.sdk.exceptions.ProviderNotReadyError; import java.lang.reflect.Field; -import java.util.List; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -320,14 +332,30 @@ protected Class loadEvaluatorClass() throws ClassNotFoundException { } @Test - public void testGetProviderHooksReturnsFlagEvalMetricsHook() { - Provider provider = + public void testMissingAgentMethodReportsCompatibilityError() throws Exception { + final Evaluator evaluator = mock(Evaluator.class); + when(evaluator.initialize(eq(30L), eq(SECONDS), any())) + .thenThrow(new NoSuchMethodError("FeatureFlaggingGateway.getConfigSnapshot")); + final Provider provider = new Provider(new Options(), evaluator); + + final FatalError error = assertThrows(FatalError.class, () -> provider.initialize(null)); + + assertThat( + error.getMessage(), + equalTo( + "Failed to initialize provider: a required Datadog agent class or method is missing." + + " The Datadog Java agent is likely absent, or older than this dd-openfeature" + + " release.")); + assertTrue(error.getCause() instanceof NoSuchMethodError); + } + + @Test + public void testGetProviderHooksReturnsTelemetryHooks() { + final Provider provider = new Provider(new Options().initTimeout(10, MILLISECONDS), mock(Evaluator.class)); - List hooks = provider.getProviderHooks(); - // Two hooks: OTel FlagEvalMetricsHook (index 0) + FlagEvalLoggingHook (index 1) - assertThat(hooks.size(), equalTo(2)); - assertThat(hooks.get(0) instanceof FlagEvalMetricsHook, equalTo(true)); - assertThat(hooks.get(1) instanceof FlagEvalLoggingHook, equalTo(true)); + + assertHasHook(provider, FlagEvalMetricsHook.class); + assertHasHook(provider, FlagEvalLoggingHook.class); } @Test @@ -340,10 +368,75 @@ Hook buildFlagEvalLoggingHook() { } }; - List hooks = provider.getProviderHooks(); + assertHasHook(provider, FlagEvalMetricsHook.class); + assertFalse( + provider.getProviderHooks().stream().anyMatch(FlagEvalLoggingHook.class::isInstance)); + } + + @Test + public void testTelemetryDisabledProviderHasNoHooks() { + final Provider provider = + new Provider(new Options().telemetryEnabled(false), mock(Evaluator.class), Boolean.TRUE); + + assertTrue(provider.getProviderHooks().isEmpty()); + assertNull(provider.spanEnrichmentHook()); + } + + @Test + public void testTelemetryDisabledDomainSuppressesExposuresThroughReflectiveEvaluator() + throws Exception { + final Map variations = new HashMap<>(); + variations.put("on", new Variant("on", true)); + final Split split = new Split(emptyList(), "on", emptyMap(), null); + final Allocation allocation = + new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.TRUE); + final Map flags = new HashMap<>(); + flags.put( + "logged-flag", + new Flag("logged-flag", true, ValueType.BOOLEAN, variations, singletonList(allocation))); + FeatureFlaggingGateway.dispatch(new ServerConfiguration("", "", false, null, flags)); + + final AtomicReference exposure = new AtomicReference<>(); + final FeatureFlaggingGateway.ExposureListener listener = exposure::set; + FeatureFlaggingGateway.addExposureListener(listener); + try { + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setProviderAndWait("live", new Provider()); + api.setProviderAndWait("peek", new Provider(new Options().telemetryEnabled(false))); + final MutableContext context = new MutableContext("user-1"); + + assertTrue(api.getClient("peek").getBooleanValue("logged-flag", false, context)); + assertNull(exposure.get(), "the peek domain must not record an exposure"); + + assertTrue(api.getClient("live").getBooleanValue("logged-flag", false, context)); + assertNotNull(exposure.get(), "the live domain must record an exposure"); + } finally { + FeatureFlaggingGateway.removeExposureListener(listener); + } + } + + @Test + public void testOptionsRetainDefaultTimeoutWhenOnlyTelemetryIsConfigured() { + final Options options = new Options().telemetryEnabled(false); - assertThat(hooks.size(), equalTo(1)); - assertThat(hooks.get(0) instanceof FlagEvalMetricsHook, equalTo(true)); + assertThat(options.getTimeout(), equalTo(30L)); + assertThat(options.getUnit(), equalTo(SECONDS)); + assertFalse(options.isTelemetryEnabled()); + } + + @Test + public void testProviderSnapshotsOptionsAtConstruction() throws Exception { + final Options options = new Options().initTimeout(10, MILLISECONDS); + final Evaluator evaluator = mock(Evaluator.class); + when(evaluator.initialize(eq(10L), eq(MILLISECONDS), any())).thenReturn(true); + when(evaluator.hasConfiguration()).thenReturn(true); + final Provider provider = new Provider(options, evaluator, Boolean.FALSE); + + options.initTimeout(20, SECONDS).telemetryEnabled(false); + provider.initialize(null); + + verify(evaluator).initialize(eq(10L), eq(MILLISECONDS), any()); + assertHasHook(provider, FlagEvalLoggingHook.class); } @Test @@ -409,14 +502,20 @@ public void testShutdownCleansUpEvaluator() throws Exception { provider.shutdown(); verify(evaluator).shutdown(); - // After shutdown, getProviderHooks still returns a list with both OTel + logging hooks - assertThat(provider.getProviderHooks().size(), equalTo(2)); + assertHasHook(provider, FlagEvalMetricsHook.class); + assertHasHook(provider, FlagEvalLoggingHook.class); } private static void assertHasFlagEvalMetricsHook(final Provider provider) { - assertTrue( - provider.getProviderHooks().stream().anyMatch(FlagEvalMetricsHook.class::isInstance), - "flag evaluation metrics hook should be registered"); + assertHasHook(provider, FlagEvalMetricsHook.class); + } + + private static void assertHasHook( + final Provider provider, final Class hookClass) { + assertThat( + hookClass.getSimpleName() + " should be registered exactly once", + provider.getProviderHooks().stream().filter(hookClass::isInstance).count(), + equalTo(1L)); } public interface EvaluateMethod { 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 2a823bd32ef..79be6073f3f 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 @@ -10,6 +10,25 @@ public abstract class FeatureFlaggingGateway { + /** An immutable, process-wide view of the current configuration and its publication order. */ + public static final class ConfigSnapshot { + private final long version; + private final ServerConfiguration config; + + private ConfigSnapshot(final long version, final ServerConfiguration config) { + this.version = version; + this.config = config; + } + + public long getVersion() { + return version; + } + + public ServerConfiguration getConfig() { + return config; + } + } + public interface ConfigListener extends Consumer {} public interface ActivationListener { @@ -26,8 +45,8 @@ public interface SpanEnrichmentListener extends Consumer {} private static final List SPAN_ENRICHMENT_LISTENERS = new CopyOnWriteArrayList<>(); - private static final AtomicReference CURRENT_CONFIG = - new AtomicReference<>(); + private static final AtomicReference CURRENT_CONFIG = + new AtomicReference<>(new ConfigSnapshot(0, null)); /** * The active EVP flagevaluation writer. Registered by {@code FlagEvaluationWriterImpl.start()} @@ -44,9 +63,9 @@ private FeatureFlaggingGateway() {} public static void addConfigListener(final ConfigListener listener) { CONFIG_LISTENERS.add(listener); - final ServerConfiguration current = CURRENT_CONFIG.get(); - if (current != null) { - listener.accept(current); + final ConfigSnapshot current = CURRENT_CONFIG.get(); + if (current.getConfig() != null) { + listener.accept(current.getConfig()); } } @@ -55,10 +74,14 @@ public static void removeConfigListener(final ConfigListener listener) { } public static void dispatch(final ServerConfiguration config) { - CURRENT_CONFIG.set(config); + CURRENT_CONFIG.updateAndGet(current -> new ConfigSnapshot(current.getVersion() + 1, config)); CONFIG_LISTENERS.forEach(listener -> listener.accept(config)); } + public static ConfigSnapshot getConfigSnapshot() { + return CURRENT_CONFIG.get(); + } + public static void addActivationListener(final ActivationListener listener) { ACTIVATION_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 887a153f0a1..2f9c6874308 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 @@ -1,5 +1,7 @@ package datadog.trace.api.featureflag; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; @@ -78,6 +80,23 @@ void testAttachingAListenerAfterConfigured() { verifyNoMoreInteractions(configListener); } + @Test + void testDispatchPublishesVersionedConfigurationSnapshots() { + final long initialVersion = FeatureFlaggingGateway.getConfigSnapshot().getVersion(); + + FeatureFlaggingGateway.dispatch(firstConfiguration); + final FeatureFlaggingGateway.ConfigSnapshot firstSnapshot = + FeatureFlaggingGateway.getConfigSnapshot(); + FeatureFlaggingGateway.dispatch(secondConfiguration); + final FeatureFlaggingGateway.ConfigSnapshot secondSnapshot = + FeatureFlaggingGateway.getConfigSnapshot(); + + assertEquals(initialVersion + 1, firstSnapshot.getVersion()); + assertSame(firstConfiguration, firstSnapshot.getConfig()); + assertEquals(firstSnapshot.getVersion() + 1, secondSnapshot.getVersion()); + assertSame(secondConfiguration, secondSnapshot.getConfig()); + } + @Test void testAttachingAnExposureListener() { FeatureFlaggingGateway.addExposureListener(exposureListener);