diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e4dd8a0f757..499fe5c69b6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -139,3 +139,10 @@ dd-trace-core/src/test/groovy/datadog/trace/llmobs/ @DataDog/ml-observability /internal-api/src/test/groovy/datadog/trace/api/rum/ @DataDog/rum /telemetry/src/main/java/datadog/telemetry/rum/ @DataDog/rum /telemetry/src/test/groovy/datadog/telemetry/rum/ @DataDog/rum + + +# @DataDog/feature-flagging-and-experimentation-sdk +/internal-api/src/main/java/datadog/trace/api/featureflag/ @DataDog/feature-flagging-and-experimentation-sdk +/internal-api/src/test/groovy/datadog/trace/api/featureflag/ @DataDog/feature-flagging-and-experimentation-sdk +/dd-java-agent/agent-feature-flagging/ @DataDog/feature-flagging-and-experimentation-sdk +/products/openfeature/ @DataDog/feature-flagging-and-experimentation-sdk 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 cf95f5add16..b622ef2490d 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 @@ -32,6 +32,7 @@ import datadog.trace.api.config.CrashTrackingConfig; import datadog.trace.api.config.CwsConfig; import datadog.trace.api.config.DebuggerConfig; +import datadog.trace.api.config.FeatureFlaggingConfig; import datadog.trace.api.config.GeneralConfig; import datadog.trace.api.config.IastConfig; import datadog.trace.api.config.JmxFetchConfig; @@ -125,7 +126,8 @@ private enum AgentFeature { DATA_JOBS(GeneralConfig.DATA_JOBS_ENABLED, false), AGENTLESS_LOG_SUBMISSION(GeneralConfig.AGENTLESS_LOG_SUBMISSION_ENABLED, false), LLMOBS(LlmObsConfig.LLMOBS_ENABLED, false), - LLMOBS_AGENTLESS(LlmObsConfig.LLMOBS_AGENTLESS_ENABLED, false); + LLMOBS_AGENTLESS(LlmObsConfig.LLMOBS_AGENTLESS_ENABLED, false), + FEATURE_FLAGGING(FeatureFlaggingConfig.FLAGGING_PROVIDER_ENABLED, false); private final String configKey; private final String systemProp; @@ -184,6 +186,7 @@ public boolean isEnabledByDefault() { private static boolean codeOriginEnabled = false; private static boolean distributedDebuggerEnabled = false; private static boolean agentlessLogSubmissionEnabled = false; + private static boolean featureFlaggingEnabled = false; private static void safelySetContextClassLoader(ClassLoader classLoader) { try { @@ -268,6 +271,7 @@ public static void start( codeOriginEnabled = isFeatureEnabled(AgentFeature.CODE_ORIGIN); agentlessLogSubmissionEnabled = isFeatureEnabled(AgentFeature.AGENTLESS_LOG_SUBMISSION); llmObsEnabled = isFeatureEnabled(AgentFeature.LLMOBS); + featureFlaggingEnabled = isFeatureEnabled(AgentFeature.FEATURE_FLAGGING); // setup writers when llmobs is enabled to accomodate apm and llmobs if (llmObsEnabled) { @@ -662,6 +666,7 @@ public void execute() { maybeStartDebugger(instrumentation, scoClass, sco); maybeStartRemoteConfig(scoClass, sco); maybeStartAiGuard(); + maybeStartFeatureFlagging(scoClass, sco); if (telemetryEnabled) { startTelemetry(instrumentation, scoClass, sco); @@ -1083,6 +1088,23 @@ private static void maybeStartLLMObs(Instrumentation inst, Class scoClass, Ob } } + private static void maybeStartFeatureFlagging(final Class scoClass, final Object sco) { + if (featureFlaggingEnabled) { + StaticEventLogger.begin("Feature Flagging"); + + try { + final Class ffSysClass = + AGENT_CLASSLOADER.loadClass("com.datadog.featureflag.FeatureFlaggingSystem"); + final Method ffSysMethod = ffSysClass.getMethod("start", scoClass); + ffSysMethod.invoke(null, sco); + } catch (final Throwable e) { + log.warn("Not starting Feature Flagging subsystem", e); + } + + StaticEventLogger.end("Feature Flagging"); + } + } + private static void maybeInstallLogsIntake(Class scoClass, Object sco) { if (agentlessLogSubmissionEnabled) { StaticEventLogger.begin("Logs Intake"); diff --git a/dd-java-agent/agent-feature-flagging/build.gradle b/dd-java-agent/agent-feature-flagging/build.gradle new file mode 100644 index 00000000000..880bb675380 --- /dev/null +++ b/dd-java-agent/agent-feature-flagging/build.gradle @@ -0,0 +1,42 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + +plugins { + id 'com.gradleup.shadow' +} + +apply from: "$rootDir/gradle/java.gradle" +apply from: "$rootDir/gradle/version.gradle" + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +excludedClassesCoverage += [ + // POJOs + 'com.datadog.featureflag.ExposureCache.Key', + 'com.datadog.featureflag.ExposureCache.Value' +] + +dependencies { + api libs.slf4j + implementation libs.moshi + implementation libs.jctools + + api project(':dd-trace-api') + compileOnly project(':dd-trace-core') + implementation project(':internal-api') + implementation project(':communication') + + testImplementation project(':utils:test-utils') + testImplementation project(':dd-java-agent:testing') +} + +tasks.named("shadowJar", ShadowJar) { + dependencies deps.excludeShared +} + +tasks.named("jar", Jar) { + archiveClassifier = 'unbundled' +} + diff --git a/dd-java-agent/agent-feature-flagging/src/main/java/com/datadog/featureflag/ExposureCache.java b/dd-java-agent/agent-feature-flagging/src/main/java/com/datadog/featureflag/ExposureCache.java new file mode 100644 index 00000000000..6fdd06bece7 --- /dev/null +++ b/dd-java-agent/agent-feature-flagging/src/main/java/com/datadog/featureflag/ExposureCache.java @@ -0,0 +1,61 @@ +package com.datadog.featureflag; + +import datadog.trace.api.featureflag.exposure.ExposureEvent; +import java.util.Objects; + +public interface ExposureCache { + + boolean add(ExposureEvent event); + + Value get(Key key); + + int size(); + + final class Key { + public final String flag; + public final String subject; + + public Key(final ExposureEvent event) { + this.flag = event.flag == null ? null : event.flag.key; + this.subject = event.subject == null ? null : event.subject.id; + } + + @Override + public boolean equals(final Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + final Key key = (Key) o; + return Objects.equals(flag, key.flag) && Objects.equals(subject, key.subject); + } + + @Override + public int hashCode() { + return Objects.hash(flag, subject); + } + } + + final class Value { + public final String variant; + public final String allocation; + + public Value(final ExposureEvent event) { + this.variant = event.variant == null ? null : event.variant.key; + this.allocation = event.allocation == null ? null : event.allocation.key; + } + + @Override + public boolean equals(final Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + final Value value = (Value) o; + return Objects.equals(variant, value.variant) && Objects.equals(allocation, value.allocation); + } + + @Override + public int hashCode() { + return Objects.hash(variant, allocation); + } + } +} diff --git a/dd-java-agent/agent-feature-flagging/src/main/java/com/datadog/featureflag/ExposureWriter.java b/dd-java-agent/agent-feature-flagging/src/main/java/com/datadog/featureflag/ExposureWriter.java new file mode 100644 index 00000000000..cb733aa51ea --- /dev/null +++ b/dd-java-agent/agent-feature-flagging/src/main/java/com/datadog/featureflag/ExposureWriter.java @@ -0,0 +1,14 @@ +package com.datadog.featureflag; + +import datadog.trace.api.featureflag.FeatureFlaggingGateway; + +/** + * Defines an exposure writer responsible for sending exposure events to the EVP proxy. + * Implementations should use a background thread to perform these operations asynchronously. + */ +public interface ExposureWriter extends AutoCloseable, FeatureFlaggingGateway.ExposureListener { + + void init(); + + void close(); +} diff --git a/dd-java-agent/agent-feature-flagging/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java b/dd-java-agent/agent-feature-flagging/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java new file mode 100644 index 00000000000..5ef749246bb --- /dev/null +++ b/dd-java-agent/agent-feature-flagging/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java @@ -0,0 +1,192 @@ +package com.datadog.featureflag; + +import static datadog.trace.util.AgentThreadFactory.AgentThread.FEATURE_FLAG_EXPOSURE_PROCESSOR; +import static datadog.trace.util.AgentThreadFactory.newAgentThread; +import static java.util.concurrent.TimeUnit.SECONDS; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; +import datadog.communication.BackendApi; +import datadog.communication.BackendApiFactory; +import datadog.communication.ddagent.SharedCommunicationObjects; +import datadog.trace.api.Config; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.exposure.ExposureEvent; +import datadog.trace.api.featureflag.exposure.ExposuresRequest; +import datadog.trace.api.intake.Intake; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import okhttp3.RequestBody; +import org.jctools.queues.MpscBlockingConsumerArrayQueue; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ExposureWriterImpl implements ExposureWriter { + + private static final Logger LOGGER = LoggerFactory.getLogger(ExposureWriterImpl.class); + private static final int DEFAULT_CAPACITY = 1 << 16; // 65536 elements + private static final int DEFAULT_FLUSH_INTERVAL_IN_SECONDS = 1; + private static final int FLUSH_THRESHOLD = 100; + + private final MpscBlockingConsumerArrayQueue queue; + private final Thread serializerThread; + + public ExposureWriterImpl(final SharedCommunicationObjects sco, final Config config) { + this(DEFAULT_CAPACITY, DEFAULT_FLUSH_INTERVAL_IN_SECONDS, SECONDS, sco, config); + } + + ExposureWriterImpl( + final int capacity, + final long flushInterval, + final TimeUnit timeUnit, + final SharedCommunicationObjects sco, + final Config config) { + this.queue = new MpscBlockingConsumerArrayQueue<>(capacity); + final Map context = new HashMap<>(4); + context.put("service", config.getServiceName() == null ? "unknown" : config.getServiceName()); + if (config.getEnv() != null) { + context.put("env", config.getEnv()); + } + if (config.getVersion() != null) { + context.put("version", config.getVersion()); + } + final ExposureSerializingHandler serializer = + new ExposureSerializingHandler( + new BackendApiFactory(config, sco), + queue, + flushInterval, + timeUnit, + context, + this::close); + this.serializerThread = newAgentThread(FEATURE_FLAG_EXPOSURE_PROCESSOR, serializer); + } + + @Override + public void init() { + FeatureFlaggingGateway.addExposureListener(this); + this.serializerThread.start(); + } + + @Override + public void close() { + FeatureFlaggingGateway.removeExposureListener(this); + if (this.serializerThread.isAlive()) { + this.serializerThread.interrupt(); + } + } + + @Override + public void accept(final ExposureEvent event) { + queue.offer(event); + } + + private static class ExposureSerializingHandler implements Runnable { + private final MpscBlockingConsumerArrayQueue queue; + private final long ticksRequiredToFlush; + private long lastTicks; + + private final JsonAdapter jsonAdapter; + private final BackendApiFactory backendApiFactory; + private BackendApi evp; + + private final Map context; + private final ExposureCache cache; + + private final List buffer = new ArrayList<>(); + private final Runnable errorCallback; + + public ExposureSerializingHandler( + final BackendApiFactory backendApiFactory, + final MpscBlockingConsumerArrayQueue queue, + final long flushInterval, + final TimeUnit timeUnit, + final Map context, + final Runnable errorCallback) { + this.queue = queue; + this.cache = new LRUExposureCache(queue.capacity()); + this.jsonAdapter = new Moshi.Builder().build().adapter(ExposuresRequest.class); + this.backendApiFactory = backendApiFactory; + this.context = context; + + this.lastTicks = System.nanoTime(); + this.ticksRequiredToFlush = timeUnit.toNanos(flushInterval); + + this.errorCallback = errorCallback; + + LOGGER.debug("starting exposure serializer"); + } + + @Override + public void run() { + evp = backendApiFactory.createBackendApi(Intake.EVENT_PLATFORM); + if (evp == null) { + errorCallback.run(); + throw new IllegalArgumentException("EVP Proxy not available"); + } + try { + runDutyCycle(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + LOGGER.debug("exposure processor worker exited. submitting exposures stopped."); + } + + private void runDutyCycle() throws InterruptedException { + final Thread thread = Thread.currentThread(); + while (!thread.isInterrupted()) { + ExposureEvent event; + while ((event = queue.poll(100, TimeUnit.MILLISECONDS)) != null) { + if (addToBuffer(event)) { + consumeBatch(); + break; + } + } + flushIfNecessary(); + } + } + + private void consumeBatch() { + queue.drain(this::addToBuffer, queue.size()); + } + + /** Adds an element to the buffer taking care of duplicated exposures thanks to the LRU cache */ + private boolean addToBuffer(final ExposureEvent event) { + if (cache.add(event)) { + buffer.add(event); + return true; + } + return false; + } + + protected void flushIfNecessary() { + if (buffer.isEmpty()) { + return; + } + if (shouldFlush()) { + try { + final ExposuresRequest exposures = new ExposuresRequest(this.context, this.buffer); + final String reqBod = jsonAdapter.toJson(exposures); + final RequestBody requestBody = + RequestBody.create(okhttp3.MediaType.parse("application/json"), reqBod); + evp.post("exposures", requestBody, stream -> null, null, false); + this.buffer.clear(); + } catch (Exception e) { + LOGGER.error("Could not submit exposures", e); + } + } + } + + private boolean shouldFlush() { + long nanoTime = System.nanoTime(); + long ticks = nanoTime - lastTicks; + if (ticks > ticksRequiredToFlush || queue.size() >= FLUSH_THRESHOLD) { + lastTicks = nanoTime; + return true; + } + return false; + } + } +} diff --git a/dd-java-agent/agent-feature-flagging/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java b/dd-java-agent/agent-feature-flagging/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java new file mode 100644 index 00000000000..02689767bad --- /dev/null +++ b/dd-java-agent/agent-feature-flagging/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java @@ -0,0 +1,44 @@ +package com.datadog.featureflag; + +import datadog.communication.ddagent.SharedCommunicationObjects; +import datadog.trace.api.Config; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class FeatureFlaggingSystem { + + private static final Logger LOGGER = LoggerFactory.getLogger(FeatureFlaggingSystem.class); + + private static volatile RemoteConfigService CONFIG_SERVICE; + private static volatile ExposureWriter EXPOSURE_WRITER; + + private FeatureFlaggingSystem() {} + + public static void start(final SharedCommunicationObjects sco) { + LOGGER.debug("Feature Flagging system starting"); + final Config config = Config.get(); + + if (!config.isRemoteConfigEnabled()) { + throw new IllegalStateException("Feature Flagging system started without RC"); + } + CONFIG_SERVICE = new RemoteConfigServiceImpl(sco, config); + CONFIG_SERVICE.init(); + + EXPOSURE_WRITER = new ExposureWriterImpl(sco, config); + EXPOSURE_WRITER.init(); + + LOGGER.debug("Feature Flagging system started"); + } + + public static void stop() { + if (EXPOSURE_WRITER != null) { + EXPOSURE_WRITER.close(); + EXPOSURE_WRITER = null; + } + if (CONFIG_SERVICE != null) { + CONFIG_SERVICE.close(); + CONFIG_SERVICE = null; + } + LOGGER.debug("Feature Flagging system stopped"); + } +} diff --git a/dd-java-agent/agent-feature-flagging/src/main/java/com/datadog/featureflag/LRUExposureCache.java b/dd-java-agent/agent-feature-flagging/src/main/java/com/datadog/featureflag/LRUExposureCache.java new file mode 100644 index 00000000000..4aab2c03f05 --- /dev/null +++ b/dd-java-agent/agent-feature-flagging/src/main/java/com/datadog/featureflag/LRUExposureCache.java @@ -0,0 +1,55 @@ +package com.datadog.featureflag; + +import datadog.trace.api.featureflag.exposure.ExposureEvent; +import java.util.LinkedHashMap; +import java.util.Map; + +public class LRUExposureCache implements ExposureCache { + + private final Map cache; + + public LRUExposureCache(final int capacity) { + cache = new FIFOCache<>(capacity); + } + + @Override + public boolean add(final ExposureEvent event) { + final Key key = new Key(event); + final Value oldValue = cache.get(key); + if (oldValue == null) { + cache.put(key, new Value(event)); + return true; + } + final Value newValue = new Value(event); + if (!newValue.equals(oldValue)) { + cache.remove(key); // ensure LRU semantics + cache.put(key, newValue); + return true; + } + return false; + } + + @Override + public Value get(final Key key) { + return cache.get(key); + } + + @Override + public int size() { + return cache.size(); + } + + private static class FIFOCache extends LinkedHashMap { + + private final int capacity; + + private FIFOCache(final int capacity) { + this.capacity = capacity; + } + + @Override + protected boolean removeEldestEntry(final Map.Entry eldest) { + return size() > capacity; + } + } +} diff --git a/dd-java-agent/agent-feature-flagging/src/main/java/com/datadog/featureflag/RemoteConfigService.java b/dd-java-agent/agent-feature-flagging/src/main/java/com/datadog/featureflag/RemoteConfigService.java new file mode 100644 index 00000000000..5f84a78f7d4 --- /dev/null +++ b/dd-java-agent/agent-feature-flagging/src/main/java/com/datadog/featureflag/RemoteConfigService.java @@ -0,0 +1,10 @@ +package com.datadog.featureflag; + +import java.io.Closeable; + +public interface RemoteConfigService extends Closeable { + + void init(); + + void close(); +} diff --git a/dd-java-agent/agent-feature-flagging/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java b/dd-java-agent/agent-feature-flagging/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java new file mode 100644 index 00000000000..f27eb003082 --- /dev/null +++ b/dd-java-agent/agent-feature-flagging/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java @@ -0,0 +1,64 @@ +package com.datadog.featureflag; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; +import datadog.communication.ddagent.SharedCommunicationObjects; +import datadog.remoteconfig.Capabilities; +import datadog.remoteconfig.ConfigurationChangesTypedListener; +import datadog.remoteconfig.ConfigurationDeserializer; +import datadog.remoteconfig.ConfigurationPoller; +import datadog.remoteconfig.PollingRateHinter; +import datadog.remoteconfig.Product; +import datadog.trace.api.Config; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import javax.annotation.Nullable; +import okio.Okio; + +public class RemoteConfigServiceImpl + implements RemoteConfigService, ConfigurationChangesTypedListener { + + private final ConfigurationPoller configurationPoller; + + public RemoteConfigServiceImpl(final SharedCommunicationObjects sco, final Config config) { + configurationPoller = sco.configurationPoller(config); + } + + @Override + public void init() { + configurationPoller.addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + configurationPoller.addListener( + Product.FFE_FLAGS, UniversalFlagConfigDeserializer.INSTANCE, this); + configurationPoller.start(); + } + + @Override + public void close() { + configurationPoller.removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + configurationPoller.stop(); + } + + @Override + public void accept( + final String configKey, + @Nullable final ServerConfiguration configuration, + final PollingRateHinter pollingRateHinter) { + FeatureFlaggingGateway.dispatch(configuration); + } + + static class UniversalFlagConfigDeserializer + implements ConfigurationDeserializer { + + static final UniversalFlagConfigDeserializer INSTANCE = new UniversalFlagConfigDeserializer(); + + private static final JsonAdapter V1_ADAPTER = + new Moshi.Builder().build().adapter(ServerConfiguration.class); + + @Override + public ServerConfiguration deserialize(final byte[] content) throws IOException { + return V1_ADAPTER.fromJson(Okio.buffer(Okio.source(new ByteArrayInputStream(content)))); + } + } +} diff --git a/dd-java-agent/agent-feature-flagging/src/test/groovy/com/datadog/featureflag/ExposureWriterTests.groovy b/dd-java-agent/agent-feature-flagging/src/test/groovy/com/datadog/featureflag/ExposureWriterTests.groovy new file mode 100644 index 00000000000..b84b3855a86 --- /dev/null +++ b/dd-java-agent/agent-feature-flagging/src/test/groovy/com/datadog/featureflag/ExposureWriterTests.groovy @@ -0,0 +1,317 @@ +package com.datadog.featureflag + +import static datadog.trace.agent.test.server.http.TestHttpServer.httpServer +import static java.util.concurrent.TimeUnit.MILLISECONDS + +import com.squareup.moshi.Moshi +import datadog.communication.ddagent.DDAgentFeaturesDiscovery +import datadog.communication.ddagent.SharedCommunicationObjects +import datadog.trace.agent.test.server.http.TestHttpServer +import datadog.trace.api.Config +import datadog.trace.api.IdGenerationStrategy +import datadog.trace.api.featureflag.FeatureFlaggingGateway +import datadog.trace.api.featureflag.exposure.Allocation +import datadog.trace.api.featureflag.exposure.ExposureEvent +import datadog.trace.api.featureflag.exposure.ExposuresRequest +import datadog.trace.api.featureflag.exposure.Flag +import datadog.trace.api.featureflag.exposure.Subject +import datadog.trace.api.featureflag.exposure.Variant +import datadog.trace.test.util.DDSpecification +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okio.Okio +import spock.lang.AutoCleanup +import spock.lang.Shared +import spock.util.concurrent.PollingConditions + +class ExposureWriterTests extends DDSpecification { + + @Shared + protected final Queue requests = new ConcurrentLinkedQueue<>() + + @Shared + protected final Set failed = Collections.newSetFromMap(new ConcurrentHashMap()) + + @Shared + @AutoCleanup + protected TestHttpServer server = httpServer { + final adapter = new Moshi.Builder().build().adapter(ExposuresRequest) + handlers { + prefix("/evp_proxy/api/v2/exposures") { + final exposuresRequest = adapter.fromJson(Okio.buffer(Okio.source(new ByteArrayInputStream(request.body)))) + final serviceName = exposuresRequest.context.service + final failForever = serviceName == 'fail-forever' + final fail = serviceName.startsWith('fail') && (failed.add(serviceName) || failForever) + if (fail) { + response.status(500).send('Boom!!!') + } else { + requests.add(exposuresRequest) + response.status(200).send('OK') + } + } + } + } + + @Shared + protected PollingConditions poll = new PollingConditions(timeout: 5) + + @Shared + protected SharedCommunicationObjects sco = Stub(SharedCommunicationObjects) { + featuresDiscovery(_ as Config) >> { + return Mock(DDAgentFeaturesDiscovery) { + supportsEvpProxy() >> true + getEvpProxyEndpoint() >> '/evp_proxy/' + } + } + }.tap { + agentUrl = HttpUrl.get(server.address) + agentHttpClient = new OkHttpClient.Builder().build() + } + + void cleanup() { + requests.clear() + failed.clear() + } + + void 'test exposure event writes'() { + setup: + def config = mockConfig(service, env, version) + def exposures = (1..5).collect { buildExposure() } + def writer = new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sco, config) + writer.init() + + when: + exposures.each { writer.accept(it) } + + then: + poll.eventually { + assert !requests.empty + requests.each { + assert it.context.service == service ?: 'unknown' + if (env) { + assert it.context.env == env + } + if (version) { + assert it.context.version == version.toString() + } + } + final received = requests*.exposures.flatten() as List + assertExposures(received, exposures) + } + + cleanup: + writer.close() + + where: + service | env | version + null | null | null + 'test-service' | 'test' | '23' + 'test-service' | null | '23' + 'test-service' | 'test' | null + } + + void 'test lru cache'() { + setup: + def config = mockConfig('test-service') + def exposures = (0..5).collect { buildExposure() } + def writer = new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sco, config) + writer.init() + + when: 'populating the cache' + exposures.each { writer.accept(it) } + + then: 'all events are written' + new PollingConditions(timeout: 1).eventually { + requests*.exposures.flatten().size() == exposures.size() + } + + when: 'publishing duplicate events' + exposures.each { writer.accept(it) } + + then: 'no events are written' + MILLISECONDS.sleep(300) // wait until a flush happens + requests*.exposures.flatten().size() == exposures.size() + + when: 'a new event is generated' + writer.accept(buildExposure()) + + then: 'oldest event is evicted and the new one is submitted' + poll.eventually { + requests*.exposures.flatten().size() == exposures.size() + 1 + } + + cleanup: + writer.close() + } + + void 'test high load scenario'() { + setup: + def config = mockConfig('test-service') + def exposuresPerThread = 100 + def random = new Random() + def threads = Runtime.runtime.availableProcessors() + def executor = Executors.newFixedThreadPool(threads) + def exposures = (1..(threads * exposuresPerThread)).collect { + buildExposure() + } + def latch = new CountDownLatch(1) + def writer = new ExposureWriterImpl(sco, config) + writer.init() + + when: + def futures = exposures.collate(exposuresPerThread).collect { partition -> + executor.submit { + latch.await() + partition.each { + MILLISECONDS.sleep(random.nextInt(2)) + writer.accept(it) + } + return true + } + } + latch.countDown() // start threads + + then: + futures.each { it.get() } // wait for all threads to finish + poll.eventually { + final received = requests*.exposures.flatten() as List + assertExposures(received, exposures) + } + + cleanup: + writer.close() + executor.shutdownNow() + } + + void 'test failures are retried'() { + setup: + def config = mockConfig(serviceName) + def writer = new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sco, config) + writer.init() + + when: + writer.accept(buildExposure()) + + then: + MILLISECONDS.sleep(500) // wait for a flush to happen + final found = requests.find { it.context.service == serviceName } + if (finallyFail) { + assert found == null: requests + } else { + assert found != null: requests + } + + cleanup: + writer.close() + + where: + serviceName | finallyFail + 'fail-once' | false + 'fail-forever' | true + } + + void 'test writer stops receiving exposures if evp proxy is not available'() { + given: + final sco = Stub(SharedCommunicationObjects) { + featuresDiscovery(_ as Config) >> { + return Mock(DDAgentFeaturesDiscovery) { + supportsEvpProxy() >> false + } + } + } + def writer = new ExposureWriterImpl(sco, Config.get()) + + when: + writer.init() + + then: + poll.eventually { + assert !writer.serializerThread.isAlive() + } + + when: + FeatureFlaggingGateway.dispatch(buildExposure()) + + then: + writer.queue.size() == 0 + + cleanup: + writer.close() + } + + private Config mockConfig(String serviceName, String env = 'test', String version = '0.0.0') { + return Mock(Config) { + getIdGenerationStrategy() >> IdGenerationStrategy.fromName("RANDOM") + getServiceName() >> serviceName + getEnv() >> env + getVersion() >> version + } + } + + private static void assertExposures(final List receivedExposures, final List expectedExposures) { + assert receivedExposures.size() == expectedExposures.size() + final received = new TreeSet(ExposureWriterTests::compare) + received.addAll(expectedExposures) + assert received.containsAll(expectedExposures) + } + + private static int compare(final ExposureEvent a, final ExposureEvent b) { + if (a.is(b)) { + return 0 + } + if (a == null) { + return -1 + } + if (b == null) { + return 1 + } + + def result = a.timestamp <=> b.timestamp + if (result) { + return result + } + + result = (a.flag?.key ?: '') <=> (b.flag?.key ?: '') + if (result) { + return result + } + + result = (a.variant?.key ?: '') <=> (b.variant?.key ?: '') + if (result) { + return result + } + + result = (a.allocation?.key ?: '') <=> (b.allocation?.key ?: '') + if (result) { + return result + } + + result = (a.subject?.id ?: '') <=> (b.subject?.id ?: '') + if (result) { + return result + } + + final aEntry = a.subject?.attributes?.entrySet()?.iterator()?.next() + final bEntry = b.subject?.attributes?.entrySet()?.iterator()?.next() + result = (aEntry?.key ?: '') <=> (bEntry?.key ?: '') + if (result) { + return result + } + return (aEntry?.value?.toString() ?: '') <=> (bEntry?.value?.toString() ?: '') + } + + private static ExposureEvent buildExposure() { + final idx = UUID.randomUUID().toString() + return new ExposureEvent( + System.currentTimeMillis(), + new Allocation("Allocation_$idx"), + new Flag("Flag_$idx"), + new Variant("Variant_$idx"), + new Subject("Subject_$idx", [("key_$idx".toString()): "value_$idx".toString()]) + ) + } +} diff --git a/dd-java-agent/agent-feature-flagging/src/test/groovy/com/datadog/featureflag/FeatureFlaggingSystemTest.groovy b/dd-java-agent/agent-feature-flagging/src/test/groovy/com/datadog/featureflag/FeatureFlaggingSystemTest.groovy new file mode 100644 index 00000000000..77dd0ade22d --- /dev/null +++ b/dd-java-agent/agent-feature-flagging/src/test/groovy/com/datadog/featureflag/FeatureFlaggingSystemTest.groovy @@ -0,0 +1,53 @@ +package com.datadog.featureflag + +import datadog.communication.ddagent.SharedCommunicationObjects +import datadog.remoteconfig.Capabilities +import datadog.remoteconfig.ConfigurationDeserializer +import datadog.remoteconfig.ConfigurationPoller +import datadog.remoteconfig.Product +import datadog.trace.api.Config +import datadog.trace.test.util.DDSpecification +import okhttp3.HttpUrl + +class FeatureFlaggingSystemTest extends DDSpecification { + + void 'test feature flag system initialization'() { + setup: + final poller = Mock(ConfigurationPoller) + final sco = Mock(SharedCommunicationObjects) + sco.agentUrl = HttpUrl.get('http://localhost') + + when: + FeatureFlaggingSystem.start(sco) + + then: + 1 * sco.configurationPoller(_ as Config) >> { poller } + 1 * poller.addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES) + 1 * poller.addListener(Product.FFE_FLAGS, _ as ConfigurationDeserializer, _) + 1 * poller.start() + 0 * _ + + when: + FeatureFlaggingSystem.stop() + + then: + 1 * poller.removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES) + 1 * poller.stop() + 0 * _ + } + + void 'test that a poller is required'() { + setup: + injectSysConfig('remote_configuration.enabled', 'false') + final sco = Mock(SharedCommunicationObjects) + + when: + FeatureFlaggingSystem.start(sco) + + then: + thrown(IllegalStateException) + + cleanup: + FeatureFlaggingSystem.stop() + } +} diff --git a/dd-java-agent/agent-feature-flagging/src/test/groovy/com/datadog/featureflag/LRUExposureCacheTest.groovy b/dd-java-agent/agent-feature-flagging/src/test/groovy/com/datadog/featureflag/LRUExposureCacheTest.groovy new file mode 100644 index 00000000000..49edecf18ec --- /dev/null +++ b/dd-java-agent/agent-feature-flagging/src/test/groovy/com/datadog/featureflag/LRUExposureCacheTest.groovy @@ -0,0 +1,213 @@ +package com.datadog.featureflag + +import datadog.trace.api.featureflag.exposure.Allocation +import datadog.trace.api.featureflag.exposure.ExposureEvent +import datadog.trace.api.featureflag.exposure.Flag +import datadog.trace.api.featureflag.exposure.Subject +import datadog.trace.api.featureflag.exposure.Variant +import spock.lang.Specification + +class LRUExposureCacheTest extends Specification { + + void 'test adding elements'() { + given: + final cache = new LRUExposureCache(5) + final event = createEvent('flag', 'subject', 'variant', 'allocation') + + when: + final added = cache.add(event) + + then: + added + cache.size() == 1 + } + + void 'test adding duplicate events returns false'() { + given: + final cache = new LRUExposureCache(5) + final event = createEvent('flag', 'subject', 'variant', 'allocation') + + when: + cache.add(event) + final duplicateAdded = cache.add(event) + + then: + !duplicateAdded + cache.size() == 1 + } + + void 'test adding events with same key but different details updates cache'() { + given: + final cache = new LRUExposureCache(5) + final event1 = createEvent('flag', 'subject', 'variant1', 'allocation1') + final event2 = createEvent('flag', 'subject', 'variant2', 'allocation2') + final key = new ExposureCache.Key(event1) + + when: + final added1 = cache.add(event1) + final added2 = cache.add(event2) + final retrieved = cache.get(key) + + then: + added1 + added2 + cache.size() == 1 + retrieved.variant == 'variant2' + retrieved.allocation == 'allocation2' + } + + void 'test LRU eviction when capacity exceeded'() { + given: + final cache = new LRUExposureCache(2) + final event1 = createEvent('flag1', 'subject1', 'variant1', 'allocation1') + final event2 = createEvent('flag2', 'subject2', 'variant2', 'allocation2') + final event3 = createEvent('flag3', 'subject3', 'variant3', 'allocation3') + final key1 = new ExposureCache.Key(event1) + final key3 = new ExposureCache.Key(event3) + + when: + cache.add(event1) + cache.add(event2) + cache.add(event3) + + then: + cache.size() == 2 + cache.get(key1) == null // event1 should be evicted + cache.get(key3) != null // event3 should be present + cache.get(key3).variant == 'variant3' + cache.get(key3).allocation == 'allocation3' + } + + void 'test single capacity cache'() { + given: + final cache = new LRUExposureCache(1) + final event1 = createEvent('flag1', 'subject1', 'variant1', 'allocation1') + final event2 = createEvent('flag2', 'subject2', 'variant2', 'allocation2') + + when: + cache.add(event1) + cache.add(event2) + + then: + cache.size() == 1 + } + + void 'test zero capacity cache'() { + given: + final cache = new LRUExposureCache(0) + final event = createEvent('flag', 'subject', 'variant', 'allocation') + + when: + final added = cache.add(event) + + then: + added + cache.size() == 0 + } + + void 'test empty cache size'() { + given: + final cache = new LRUExposureCache(5) + + expect: + cache.size() == 0 + } + + void 'test multiple additions with same flag different subjects'() { + given: + final cache = new LRUExposureCache(10) + final events = [] + for (int i = 0; i < 5; i++) { + events << createEvent('flag', "subject${i}", 'variant', 'allocation') + } + + when: + def results = events.collect { cache.add(it) } + + then: + results.every { it == true } + cache.size() == 5 + } + + void 'test multiple additions with same subject different flags'() { + given: + final cache = new LRUExposureCache(10) + final events = [] + for (int i = 0; i < 5; i++) { + events << createEvent("flag${i}", 'subject', 'variant', 'allocation') + } + + when: + def results = events.collect { cache.add(it) } + + then: + results.every { it == true } + cache.size() == 5 + } + + void 'test key equality with null values'() { + given: + final cache = new LRUExposureCache(5) + final event1 = new ExposureEvent( + System.currentTimeMillis(), + new Allocation('allocation'), + new Flag(null), + new Variant('variant'), + new Subject(null, [:]) + ) + final event2 = new ExposureEvent( + System.currentTimeMillis(), + new Allocation('allocation'), + new Flag(null), + new Variant('variant'), + new Subject(null, [:]) + ) + + when: + cache.add(event1) + final duplicateAdded = cache.add(event2) + + then: + !duplicateAdded + cache.size() == 1 + } + + void 'test updating existing key maintains LRU position'() { + given: + final cache = new LRUExposureCache(3) + final event1 = createEvent('flag1', 'subject1', 'variant1', 'allocation1') + final event2 = createEvent('flag2', 'subject2', 'variant2', 'allocation2') + final event3 = createEvent('flag3', 'subject3', 'variant3', 'allocation3') + final event1Updated = createEvent('flag1', 'subject1', 'variant2', 'allocation2') + final event4 = createEvent('flag4', 'subject4', 'variant4', 'allocation4') + final key1 = new ExposureCache.Key(event1) + final key2 = new ExposureCache.Key(event2) + final key4 = new ExposureCache.Key(event4) + + when: + cache.add(event1) + cache.add(event2) + cache.add(event3) + cache.add(event1Updated) // Updates event1, moves to most recent + cache.add(event4) // Should evict event2, not event1 + + then: + cache.size() == 3 + cache.get(key1) != null // event1 should be updated and present + cache.get(key1).variant == 'variant2' // verify it was updated + cache.get(key1).allocation == 'allocation2' + cache.get(key2) == null // event2 should be evicted + cache.get(key4) != null // event4 should be present + cache.get(key4).variant == 'variant4' + } + + private static ExposureEvent createEvent(String flag, String subject, String variant, String allocation) { + return new ExposureEvent( + System.currentTimeMillis(), + new Allocation(allocation), + new Flag(flag), + new Variant(variant), + new Subject(subject, [:]) + ) + } +} diff --git a/dd-java-agent/agent-feature-flagging/src/test/groovy/com/datadog/featureflag/RemoteConfigServiceTest.groovy b/dd-java-agent/agent-feature-flagging/src/test/groovy/com/datadog/featureflag/RemoteConfigServiceTest.groovy new file mode 100644 index 00000000000..f9dc12cd99b --- /dev/null +++ b/dd-java-agent/agent-feature-flagging/src/test/groovy/com/datadog/featureflag/RemoteConfigServiceTest.groovy @@ -0,0 +1,64 @@ +package com.datadog.featureflag + +import datadog.communication.ddagent.SharedCommunicationObjects +import datadog.remoteconfig.ConfigurationDeserializer +import datadog.remoteconfig.ConfigurationPoller +import datadog.remoteconfig.PollingRateHinter +import datadog.remoteconfig.Product +import datadog.trace.api.Config +import datadog.trace.api.featureflag.FeatureFlaggingGateway +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration +import datadog.trace.test.util.DDSpecification + +class RemoteConfigServiceTest extends DDSpecification { + + private FeatureFlaggingGateway.ConfigListener listener + + void setup() { + listener = Mock(FeatureFlaggingGateway.ConfigListener) + } + + void cleanup() { + FeatureFlaggingGateway.removeConfigListener(listener) + } + + void 'test new config received'() { + setup: + def poller = Mock(ConfigurationPoller) + final sco = Mock(SharedCommunicationObjects) { + configurationPoller(_ as Config) >> poller + } + FeatureFlaggingGateway.addConfigListener(listener) + final service = new RemoteConfigServiceImpl(sco, Config.get()) + final config = """ +{ + "createdAt":"2024-04-17T19:40:53.716Z", + "format":"SERVER", + "environment":{ + "name":"Test" + }, + "flags":{ + + } +} +""".bytes + ConfigurationDeserializer deserializer = null + + when: + service.init() + + then: + 1 * poller.addListener(Product.FFE_FLAGS, _ as ConfigurationDeserializer, _) >> { + deserializer = it[1] as ConfigurationDeserializer + } + + when: + service.accept('test', deserializer.deserialize(config), Mock(PollingRateHinter)) + + then: + 1 * listener.accept(_ as ServerConfiguration) + + cleanup: + FeatureFlaggingGateway.removeConfigListener(listener) + } +} diff --git a/dd-java-agent/build.gradle b/dd-java-agent/build.gradle index 8831b322867..e69b35feaba 100644 --- a/dd-java-agent/build.gradle +++ b/dd-java-agent/build.gradle @@ -189,6 +189,7 @@ includeSubprojShadowJar(project(':dd-java-agent:agent-ci-visibility'), 'ci-visib includeSubprojShadowJar(project(':dd-java-agent:agent-llmobs'), 'llm-obs', includedJarFileTree) includeSubprojShadowJar(project(':dd-java-agent:agent-logs-intake'), 'logs-intake', includedJarFileTree) includeSubprojShadowJar(project(':dd-java-agent:cws-tls'), 'cws-tls', includedJarFileTree) +includeSubprojShadowJar(project(':dd-java-agent:agent-feature-flagging'), 'feature-flagging', includedJarFileTree) def sharedShadowJar = tasks.register('sharedShadowJar', ShadowJar) { it.configurations = [project.configurations.sharedShadowInclude] diff --git a/dd-trace-api/build.gradle.kts b/dd-trace-api/build.gradle.kts index 43637568c03..191b476b2fb 100644 --- a/dd-trace-api/build.gradle.kts +++ b/dd-trace-api/build.gradle.kts @@ -35,6 +35,7 @@ val excludedClassesCoverage by extra( "datadog.trace.api.civisibility.noop.NoOpDDTestSession", "datadog.trace.api.civisibility.noop.NoOpDDTestSuite", "datadog.trace.api.config.AIGuardConfig", + "datadog.trace.api.config.FeatureFlagConfig", "datadog.trace.api.config.ProfilingConfig", "datadog.trace.api.interceptor.MutableSpan", "datadog.trace.api.profiling.Profiling", diff --git a/dd-trace-api/src/main/java/datadog/trace/api/config/FeatureFlaggingConfig.java b/dd-trace-api/src/main/java/datadog/trace/api/config/FeatureFlaggingConfig.java new file mode 100644 index 00000000000..28151f88864 --- /dev/null +++ b/dd-trace-api/src/main/java/datadog/trace/api/config/FeatureFlaggingConfig.java @@ -0,0 +1,6 @@ +package datadog.trace.api.config; + +public class FeatureFlaggingConfig { + + public static final String FLAGGING_PROVIDER_ENABLED = "experimental.flagging.provider.enabled"; +} diff --git a/internal-api/build.gradle.kts b/internal-api/build.gradle.kts index f05ab8f0b2a..bd0e65ffa66 100644 --- a/internal-api/build.gradle.kts +++ b/internal-api/build.gradle.kts @@ -71,6 +71,25 @@ val excludedClassesCoverage by extra( "datadog.trace.api.datastreams.StatsPoint", // Debugger "datadog.trace.api.debugger.DebuggerConfigUpdate", + // Feature lags POJOs + "datadog.trace.api.featureflag.exposure.Allocation", + "datadog.trace.api.featureflag.exposure.ExposureEvent", + "datadog.trace.api.featureflag.exposure.ExposuresRequest", + "datadog.trace.api.featureflag.exposure.Flag", + "datadog.trace.api.featureflag.exposure.Subject", + "datadog.trace.api.featureflag.exposure.Variant", + "datadog.trace.api.featureflag.ufc.v1.Allocation", + "datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration", + "datadog.trace.api.featureflag.ufc.v1.ConditionOperator", + "datadog.trace.api.featureflag.ufc.v1.Environment", + "datadog.trace.api.featureflag.ufc.v1.Flag", + "datadog.trace.api.featureflag.ufc.v1.Rule", + "datadog.trace.api.featureflag.ufc.v1.ServerConfiguration", + "datadog.trace.api.featureflag.ufc.v1.Shard", + "datadog.trace.api.featureflag.ufc.v1.ShardRange", + "datadog.trace.api.featureflag.ufc.v1.Split", + "datadog.trace.api.featureflag.ufc.v1.ValueType", + "datadog.trace.api.featureflag.ufc.v1.Variant", // Bootstrap API "datadog.trace.bootstrap.ActiveSubsystems", "datadog.trace.bootstrap.ContextStore.Factory", diff --git a/internal-api/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java b/internal-api/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java new file mode 100644 index 00000000000..b9d73ffa7ab --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java @@ -0,0 +1,52 @@ +package datadog.trace.api.featureflag; + +import datadog.trace.api.featureflag.exposure.ExposureEvent; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +public abstract class FeatureFlaggingGateway { + + public interface ConfigListener extends Consumer {} + + public interface ExposureListener extends Consumer {} + + private static final List CONFIG_LISTENERS = new CopyOnWriteArrayList<>(); + private static final List EXPOSURE_LISTENERS = new CopyOnWriteArrayList<>(); + + private static final AtomicReference CURRENT_CONFIG = + new AtomicReference<>(); + + 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); + } + } + + public static void removeConfigListener(final ConfigListener listener) { + CONFIG_LISTENERS.remove(listener); + } + + public static void dispatch(final ServerConfiguration config) { + CURRENT_CONFIG.set(config); + CONFIG_LISTENERS.forEach(listener -> listener.accept(config)); + } + + public static void addExposureListener(final ExposureListener listener) { + EXPOSURE_LISTENERS.add(listener); + } + + public static void removeExposureListener(final ExposureListener listener) { + EXPOSURE_LISTENERS.remove(listener); + } + + public static void dispatch(final ExposureEvent event) { + EXPOSURE_LISTENERS.forEach(listener -> listener.accept(event)); + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/featureflag/exposure/Allocation.java b/internal-api/src/main/java/datadog/trace/api/featureflag/exposure/Allocation.java new file mode 100644 index 00000000000..9a1b5d5a115 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/featureflag/exposure/Allocation.java @@ -0,0 +1,23 @@ +package datadog.trace.api.featureflag.exposure; + +import java.util.Objects; + +public class Allocation { + public final String key; + + public Allocation(final String key) { + this.key = key; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Allocation that = (Allocation) o; + return Objects.equals(key, that.key); + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/featureflag/exposure/ExposureEvent.java b/internal-api/src/main/java/datadog/trace/api/featureflag/exposure/ExposureEvent.java new file mode 100644 index 00000000000..284323d049d --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/featureflag/exposure/ExposureEvent.java @@ -0,0 +1,22 @@ +package datadog.trace.api.featureflag.exposure; + +public class ExposureEvent { + public final long timestamp; + public final Allocation allocation; + public final Flag flag; + public final Variant variant; + public final Subject subject; + + public ExposureEvent( + final long timestamp, + final Allocation allocation, + final Flag flag, + final Variant variant, + final Subject subject) { + this.timestamp = timestamp; + this.allocation = allocation; + this.flag = flag; + this.variant = variant; + this.subject = subject; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/featureflag/exposure/ExposuresRequest.java b/internal-api/src/main/java/datadog/trace/api/featureflag/exposure/ExposuresRequest.java new file mode 100644 index 00000000000..3b25028c245 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/featureflag/exposure/ExposuresRequest.java @@ -0,0 +1,15 @@ +package datadog.trace.api.featureflag.exposure; + +import java.util.List; +import java.util.Map; + +public class ExposuresRequest { + + public final Map context; + public final List exposures; + + public ExposuresRequest(final Map context, final List exposures) { + this.context = context; + this.exposures = exposures; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/featureflag/exposure/Flag.java b/internal-api/src/main/java/datadog/trace/api/featureflag/exposure/Flag.java new file mode 100644 index 00000000000..c13b123ad6c --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/featureflag/exposure/Flag.java @@ -0,0 +1,9 @@ +package datadog.trace.api.featureflag.exposure; + +public class Flag { + public final String key; + + public Flag(final String key) { + this.key = key; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/featureflag/exposure/Subject.java b/internal-api/src/main/java/datadog/trace/api/featureflag/exposure/Subject.java new file mode 100644 index 00000000000..ebd381e7069 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/featureflag/exposure/Subject.java @@ -0,0 +1,13 @@ +package datadog.trace.api.featureflag.exposure; + +import java.util.Map; + +public class Subject { + public final String id; + public final Map attributes; + + public Subject(final String id, final Map attributes) { + this.id = id; + this.attributes = attributes; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/featureflag/exposure/Variant.java b/internal-api/src/main/java/datadog/trace/api/featureflag/exposure/Variant.java new file mode 100644 index 00000000000..dfc4cbe38c7 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/featureflag/exposure/Variant.java @@ -0,0 +1,9 @@ +package datadog.trace.api.featureflag.exposure; + +public class Variant { + public final String key; + + public Variant(final String key) { + this.key = key; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java new file mode 100644 index 00000000000..aac429282d2 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java @@ -0,0 +1,27 @@ +package datadog.trace.api.featureflag.ufc.v1; + +import java.util.List; + +public class Allocation { + public final String key; + public final List rules; + public final String startAt; + public final String endAt; + public final List splits; + public final Boolean doLog; + + public Allocation( + final String key, + final List rules, + final String startAt, + final String endAt, + final List splits, + final Boolean doLog) { + this.key = key; + this.rules = rules; + this.startAt = startAt; + this.endAt = endAt; + this.splits = splits; + this.doLog = doLog; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java new file mode 100644 index 00000000000..163df03867a --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java @@ -0,0 +1,14 @@ +package datadog.trace.api.featureflag.ufc.v1; + +public class ConditionConfiguration { + public final ConditionOperator operator; + public final String attribute; + public final Object value; + + public ConditionConfiguration( + final ConditionOperator operator, final String attribute, final Object value) { + this.operator = operator; + this.attribute = attribute; + this.value = value; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionOperator.java b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionOperator.java new file mode 100644 index 00000000000..088f934b126 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionOperator.java @@ -0,0 +1,13 @@ +package datadog.trace.api.featureflag.ufc.v1; + +public enum ConditionOperator { + LT, + LTE, + GT, + GTE, + MATCHES, + NOT_MATCHES, + ONE_OF, + NOT_ONE_OF, + IS_NULL +} diff --git a/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/Environment.java b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/Environment.java new file mode 100644 index 00000000000..86d04c7d8ef --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/Environment.java @@ -0,0 +1,9 @@ +package datadog.trace.api.featureflag.ufc.v1; + +public class Environment { + public final String name; + + public Environment(String name) { + this.name = name; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/Flag.java b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/Flag.java new file mode 100644 index 00000000000..529e3145344 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/Flag.java @@ -0,0 +1,25 @@ +package datadog.trace.api.featureflag.ufc.v1; + +import java.util.List; +import java.util.Map; + +public class Flag { + public final String key; + public final boolean enabled; + public final ValueType variationType; + public final Map variations; + public final List allocations; + + public Flag( + final String key, + final boolean enabled, + final ValueType variationType, + final Map variations, + final List allocations) { + this.key = key; + this.enabled = enabled; + this.variationType = variationType; + this.variations = variations; + this.allocations = allocations; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/Rule.java b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/Rule.java new file mode 100644 index 00000000000..ed0fe60f366 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/Rule.java @@ -0,0 +1,11 @@ +package datadog.trace.api.featureflag.ufc.v1; + +import java.util.List; + +public class Rule { + public final List conditions; + + public Rule(final List conditions) { + this.conditions = conditions; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.java b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.java new file mode 100644 index 00000000000..221fc74079a --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.java @@ -0,0 +1,21 @@ +package datadog.trace.api.featureflag.ufc.v1; + +import java.util.Map; + +public class ServerConfiguration { + public final String createdAt; + public final String format; + public final Environment environment; + public final Map flags; + + public ServerConfiguration( + final String createdAt, + final String format, + final Environment environment, + final Map flags) { + this.createdAt = createdAt; + this.format = format; + this.environment = environment; + this.flags = flags; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/Shard.java b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/Shard.java new file mode 100644 index 00000000000..b94fead2581 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/Shard.java @@ -0,0 +1,15 @@ +package datadog.trace.api.featureflag.ufc.v1; + +import java.util.List; + +public class Shard { + public final String salt; + public final List ranges; + public final int totalShards; + + public Shard(final String salt, final List ranges, final int totalShards) { + this.salt = salt; + this.ranges = ranges; + this.totalShards = totalShards; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/ShardRange.java b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/ShardRange.java new file mode 100644 index 00000000000..13828b68706 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/ShardRange.java @@ -0,0 +1,11 @@ +package datadog.trace.api.featureflag.ufc.v1; + +public class ShardRange { + public final int start; + public final int end; + + public ShardRange(final int start, final int end) { + this.start = start; + this.end = end; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java new file mode 100644 index 00000000000..1782032278d --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java @@ -0,0 +1,17 @@ +package datadog.trace.api.featureflag.ufc.v1; + +import java.util.List; +import java.util.Map; + +public class Split { + public final List shards; + public final String variationKey; + public final Map extraLogging; + + public Split( + final List shards, final String variationKey, final Map extraLogging) { + this.shards = shards; + this.variationKey = variationKey; + this.extraLogging = extraLogging; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/ValueType.java b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/ValueType.java new file mode 100644 index 00000000000..6f0a973a379 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/ValueType.java @@ -0,0 +1,9 @@ +package datadog.trace.api.featureflag.ufc.v1; + +public enum ValueType { + BOOLEAN, + INTEGER, + NUMERIC, + STRING, + JSON +} diff --git a/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/Variant.java b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/Variant.java new file mode 100644 index 00000000000..26e30aa1aad --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/featureflag/ufc/v1/Variant.java @@ -0,0 +1,11 @@ +package datadog.trace.api.featureflag.ufc.v1; + +public class Variant { + public final String key; + public final Object value; + + public Variant(final String key, final Object value) { + this.key = key; + this.value = value; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/intake/Intake.java b/internal-api/src/main/java/datadog/trace/api/intake/Intake.java index 51d4473974e..2d715c2913a 100644 --- a/internal-api/src/main/java/datadog/trace/api/intake/Intake.java +++ b/internal-api/src/main/java/datadog/trace/api/intake/Intake.java @@ -15,13 +15,18 @@ public enum Intake { "ci-intake", "v2", Config::isCiVisibilityAgentlessEnabled, - Config::getCiVisibilityIntakeAgentlessUrl); + Config::getCiVisibilityIntakeAgentlessUrl), + EVENT_PLATFORM("event-platform-intake", "v2"); public final String urlPrefix; public final String version; public final Function agentlessModeEnabled; public final Function customUrl; + Intake(String urlPrefix, String version) { + this(urlPrefix, version, config -> false, config -> null); + } + Intake( String urlPrefix, String version, diff --git a/internal-api/src/main/java/datadog/trace/util/AgentThreadFactory.java b/internal-api/src/main/java/datadog/trace/util/AgentThreadFactory.java index 33affffa533..b2573d6b81f 100644 --- a/internal-api/src/main/java/datadog/trace/util/AgentThreadFactory.java +++ b/internal-api/src/main/java/datadog/trace/util/AgentThreadFactory.java @@ -59,7 +59,9 @@ public enum AgentThread { LOGS_INTAKE("dd-logs-intake"), - LLMOBS_EVALS_PROCESSOR("dd-llmobs-evals-processor"); + LLMOBS_EVALS_PROCESSOR("dd-llmobs-evals-processor"), + + FEATURE_FLAG_EXPOSURE_PROCESSOR("dd-ffe-exposure-processor"); public final String threadName; diff --git a/internal-api/src/test/groovy/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.groovy b/internal-api/src/test/groovy/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.groovy new file mode 100644 index 00000000000..6dd02e3f96e --- /dev/null +++ b/internal-api/src/test/groovy/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.groovy @@ -0,0 +1,76 @@ +package datadog.trace.api.featureflag + +import datadog.trace.api.featureflag.exposure.ExposureEvent +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration +import spock.lang.Specification + +class FeatureFlaggingGatewayTest extends Specification { + + void 'test attaching a config listener'() { + given: + def listener = Mock(FeatureFlaggingGateway.ConfigListener) + final first = Stub(ServerConfiguration) + final second = Stub(ServerConfiguration) + + when: + FeatureFlaggingGateway.addConfigListener(listener) + FeatureFlaggingGateway.dispatch(first) + + then: + 1 * listener.accept(first) + 0 * _ + + when: + FeatureFlaggingGateway.dispatch(second) + + then: + 1 * listener.accept(second) + 0 * _ + + + cleanup: + FeatureFlaggingGateway.removeConfigListener(listener) + } + + void 'test attaching a listener after configured'() { + given: + def listener = Mock(FeatureFlaggingGateway.ConfigListener) + final first = Stub(ServerConfiguration) + + when: + FeatureFlaggingGateway.dispatch(first) + FeatureFlaggingGateway.addConfigListener(listener) + + then: + 1 * listener.accept(first) + 0 * _ + + cleanup: + FeatureFlaggingGateway.removeConfigListener(listener) + } + + void 'test attaching an exposure listener'() { + given: + def listener = Mock(FeatureFlaggingGateway.ExposureListener) + final first = Stub(ExposureEvent) + final second = Stub(ExposureEvent) + + when: + FeatureFlaggingGateway.addExposureListener(listener) + FeatureFlaggingGateway.dispatch(first) + + then: + 1 * listener.accept(first) + 0 * _ + + when: + FeatureFlaggingGateway.dispatch(second) + + then: + 1 * listener.accept(second) + 0 * _ + + cleanup: + FeatureFlaggingGateway.removeExposureListener(listener) + } +} diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index fe2676bcb36..b8adc1b866b 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -184,6 +184,7 @@ "DD_EXCEPTION_REPLAY_MAX_FRAMES_TO_CAPTURE": ["A"], "DD_EXPERIMENTAL_API_SECURITY_ENABLED": ["A"], "DD_EXPERIMENTAL_DEFER_INTEGRATIONS_UNTIL": ["A"], + "DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED": ["A"], "DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED": ["A"], "DD_FORCE_CLEAR_TEXT_HTTP_FOR_INTAKE_CLIENT": ["A"], "DD_GIT_BRANCH": ["A"], diff --git a/products/openfeature/build.gradle.kts b/products/openfeature/build.gradle.kts new file mode 100644 index 00000000000..440d7eb2be5 --- /dev/null +++ b/products/openfeature/build.gradle.kts @@ -0,0 +1,67 @@ +import de.thetaphi.forbiddenapis.gradle.CheckForbiddenApis +import groovy.lang.Closure + +plugins { + `java-library` + idea +} + +apply(from = "$rootDir/gradle/java.gradle") +apply(from = "$rootDir/gradle/publish.gradle") + +val minJavaVersionForTests by extra(JavaVersion.VERSION_11) + +description = "dd-openfeature" + +idea { + module { + jdkName = "11" + } +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(11) + } +} + +dependencies { + api(libs.slf4j) + api("dev.openfeature:sdk:1.18.2") + compileOnly(project(":internal-api")) + + testImplementation(project(":internal-api")) + testImplementation(libs.bundles.junit5) + testImplementation(libs.bundles.mockito) + testImplementation(libs.moshi) + testImplementation("org.awaitility:awaitility:4.3.0") +} + +fun AbstractCompile.configureCompiler( + javaVersionInteger: Int, + compatibilityVersion: JavaVersion? = null, + unsetReleaseFlagReason: String? = null +) { + (project.extra["configureCompiler"] as Closure<*>).call( + this, + javaVersionInteger, + compatibilityVersion, + unsetReleaseFlagReason + ) +} + +tasks.withType().configureEach { + configureCompiler(11, JavaVersion.VERSION_11) +} + +tasks.withType().configureEach { + javadocTool = javaToolchains.javadocToolFor(java.toolchain) +} + +tasks.named("forbiddenApisMain") { + failOnMissingClasses = false +} + +tasks.named("jar") { + archiveBaseName.set("dd-openfeature") +} diff --git a/products/openfeature/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java b/products/openfeature/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java new file mode 100644 index 00000000000..e82767199e6 --- /dev/null +++ b/products/openfeature/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java @@ -0,0 +1,524 @@ +package datadog.trace.api.openfeature; + +import static java.util.Arrays.asList; + +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.exposure.ExposureEvent; +import datadog.trace.api.featureflag.exposure.Subject; +import datadog.trace.api.featureflag.ufc.v1.Allocation; +import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; +import datadog.trace.api.featureflag.ufc.v1.ConditionOperator; +import datadog.trace.api.featureflag.ufc.v1.Flag; +import datadog.trace.api.featureflag.ufc.v1.Rule; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import datadog.trace.api.featureflag.ufc.v1.Shard; +import datadog.trace.api.featureflag.ufc.v1.ShardRange; +import datadog.trace.api.featureflag.ufc.v1.Split; +import datadog.trace.api.featureflag.ufc.v1.Variant; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Structure; +import dev.openfeature.sdk.Value; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.TemporalAccessor; +import java.util.AbstractMap; +import java.util.Date; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener { + + private static final Logger LOGGER = LoggerFactory.getLogger(DDEvaluator.class); + private static final List DATE_FORMATTERS = + asList( + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").withZone(ZoneOffset.UTC), + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneOffset.UTC)); + private static final Set> SUPPORTED_RESOLUTION_TYPES = + new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Value.class)); + + private final Runnable configCallback; + private final AtomicReference configuration = new AtomicReference<>(); + private final CountDownLatch initializationLatch = new CountDownLatch(1); + + public DDEvaluator(final Runnable configCallback) { + this.configCallback = configCallback; + } + + @Override + public boolean initialize( + final long timeout, final TimeUnit unit, final EvaluationContext context) throws Exception { + FeatureFlaggingGateway.addConfigListener(this); + return initializationLatch.await(timeout, unit); // await for initialization + } + + @Override + public void shutdown() { + FeatureFlaggingGateway.removeConfigListener(this); + } + + @Override + public void accept(final ServerConfiguration config) { + configuration.set(config); + initializationLatch.countDown(); + configCallback.run(); + } + + @Override + public ProviderEvaluation evaluate( + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context) { + try { + final ServerConfiguration config = configuration.get(); + if (config == null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.PROVIDER_NOT_READY) + .build(); + } + + if (context == null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.INVALID_CONTEXT) + .build(); + } + + if (context.getTargetingKey() == null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.TARGETING_KEY_MISSING) + .build(); + } + + final Flag flag = config.flags.get(key); + if (flag == null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .build(); + } + + if (!flag.enabled) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.DISABLED.name()) + .build(); + } + + if (isEmpty(flag.allocations)) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.GENERAL) + .errorMessage("Missing allocations for flag " + flag.key) + .build(); + } + + final Date now = new Date(); + final String targetingKey = context.getTargetingKey(); + + for (final Allocation allocation : flag.allocations) { + if (!isAllocationActive(allocation, now)) { + continue; + } + + if (!isEmpty(allocation.rules)) { + if (!evaluateRules(allocation.rules, context)) { + continue; + } + } + + if (!isEmpty(allocation.splits)) { + for (final Split split : allocation.splits) { + if (isEmpty(split.shards)) { + return resolveVariant( + target, key, defaultValue, flag, split.variationKey, allocation, context); + } else { + // To match a split, subject must match ALL underlying shards + boolean allShardsMatch = true; + for (final Shard shard : split.shards) { + if (!matchesShard(shard, targetingKey)) { + allShardsMatch = false; + break; + } + } + if (allShardsMatch) { + return resolveVariant( + target, key, defaultValue, flag, split.variationKey, allocation, context); + } + } + } + } + } + + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.DEFAULT.name()) + .build(); + } catch (final NumberFormatException e) { + LOGGER.debug("Evaluation failed for key {}", key, e); + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.TYPE_MISMATCH) + .build(); + } catch (final Exception e) { + LOGGER.debug("Evaluation failed for key {}", key, e); + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.GENERAL) + .errorMessage(e.getMessage()) + .build(); + } + } + + private static boolean isEmpty(final List list) { + return list == null || list.isEmpty(); + } + + private static boolean isAllocationActive(final Allocation allocation, final Date now) { + final Date startDate = parseDate(allocation.startAt); + if (startDate != null && now.before(startDate)) { + return false; + } + + final Date endDate = parseDate(allocation.endAt); + if (endDate != null && now.after(endDate)) { + return false; + } + + return true; + } + + private static boolean evaluateRules(final List rules, final EvaluationContext context) { + for (final Rule rule : rules) { + if (isEmpty(rule.conditions)) { + continue; + } + + boolean allConditionsMatch = true; + for (final ConditionConfiguration condition : rule.conditions) { + if (!evaluateCondition(condition, context)) { + allConditionsMatch = false; + break; + } + } + + if (allConditionsMatch) { + return true; + } + } + return false; + } + + private static boolean evaluateCondition( + final ConditionConfiguration condition, final EvaluationContext context) { + if (condition.operator == ConditionOperator.IS_NULL) { + final Object value = resolveAttribute(condition.attribute, context); + boolean isNull = value == null; + // condition.value determines if we're checking for null (true) or not null (false) + boolean expectedNull = condition.value instanceof Boolean ? (Boolean) condition.value : true; + return isNull == expectedNull; + } + + final Object attributeValue = resolveAttribute(condition.attribute, context); + if (attributeValue == null) { + return false; + } + + switch (condition.operator) { + case MATCHES: + return matchesRegex(attributeValue, condition.value); + case NOT_MATCHES: + return !matchesRegex(attributeValue, condition.value); + case ONE_OF: + return isOneOf(attributeValue, condition.value); + case NOT_ONE_OF: + return !isOneOf(attributeValue, condition.value); + case GTE: + return compareNumber(attributeValue, condition.value, (a, b) -> a >= b); + case GT: + return compareNumber(attributeValue, condition.value, (a, b) -> a > b); + case LTE: + return compareNumber(attributeValue, condition.value, (a, b) -> a <= b); + case LT: + return compareNumber(attributeValue, condition.value, (a, b) -> a < b); + default: + return false; + } + } + + private static boolean matchesRegex(final Object attributeValue, final Object conditionValue) { + try { + final Pattern pattern = Pattern.compile(String.valueOf(conditionValue)); + return pattern.matcher(String.valueOf(attributeValue)).find(); + } catch (Exception e) { + return false; + } + } + + private static boolean isOneOf(final Object attributeValue, final Object conditionValue) { + if (!(conditionValue instanceof Iterable)) { + return false; + } + for (final Object value : (Iterable) conditionValue) { + if (valuesEqual(attributeValue, value)) { + return true; + } + } + return false; + } + + private static boolean valuesEqual(final Object a, final Object b) { + if (Objects.equals(a, b)) { + return true; + } + + if (a instanceof Number || b instanceof Number) { + return compareNumber(a, b, (first, second) -> first == second); + } + + return String.valueOf(a).equals(String.valueOf(b)); + } + + private static boolean compareNumber( + final Object attributeValue, final Object conditionValue, NumberComparator comparator) { + final double a = mapValue(Double.class, attributeValue); + final double b = mapValue(Double.class, conditionValue); + return comparator.compare(a, b); + } + + private static boolean matchesShard(final Shard shard, final String targetingKey) { + final int assignedShard = getShard(shard.salt, targetingKey, shard.totalShards); + for (final ShardRange range : shard.ranges) { + if (assignedShard >= range.start && assignedShard < range.end) { + return true; + } + } + return false; + } + + private static int getShard(final String salt, final String targetingKey, final int totalShards) { + final String hashKey = salt + "-" + targetingKey; + final String md5Hash = getMD5Hash(hashKey); + final String first8Chars = md5Hash.substring(0, Math.min(8, md5Hash.length())); + final long intFromHash = Long.parseLong(first8Chars, 16); + return (int) (intFromHash % totalShards); + } + + private static String getMD5Hash(final String input) { + try { + final MessageDigest md = MessageDigest.getInstance("MD5"); + final byte[] hashBytes = md.digest(input.getBytes(StandardCharsets.UTF_8)); + final StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + final String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("MD5 algorithm not available", e); + } + } + + private static ProviderEvaluation resolveVariant( + final Class target, + final String key, + final T defaultValue, + final Flag flag, + final String variationKey, + final Allocation allocation, + final EvaluationContext context) { + final Variant variant = flag.variations.get(variationKey); + if (variant == null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.GENERAL) + .errorMessage("Variant not found for: " + variationKey) + .build(); + } + + final ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder = + ImmutableMetadata.builder() + .addString("flagKey", flag.key) + .addString("variationType", flag.variationType.name()) + .addString("allocationKey", allocation.key); + final ProviderEvaluation result = + ProviderEvaluation.builder() + .value(mapValue(target, variant.value)) + .reason(Reason.TARGETING_MATCH.name()) + .variant(variant.key) + .flagMetadata(metadataBuilder.build()) + .build(); + final boolean doLog = allocation.doLog != null && allocation.doLog; + if (doLog) { + dispatchExposure(key, result, context); + } + return result; + } + + static Date parseDate(final String dateString) { + if (dateString == null) { + return null; + } + for (final DateTimeFormatter formatter : DATE_FORMATTERS) { + try { + final TemporalAccessor temporalAccessor = formatter.parse(dateString); + final Instant instant = Instant.from(temporalAccessor); + return Date.from(instant); + } catch (DateTimeParseException e) { + // ignore it + } + } + return null; + } + + private static Object resolveAttribute(final String name, final EvaluationContext context) { + // Special handling for "id" attribute: if not explicitly provided, use targeting key + if ("id".equals(name) && !context.keySet().contains(name)) { + return context.getTargetingKey(); + } + final Value resolved = context.getValue(name); + return context.convertValue(resolved); + } + + @SuppressWarnings("unchecked") + static T mapValue(final Class target, final Object value) { + if (value == null) { + return null; + } + if (!SUPPORTED_RESOLUTION_TYPES.contains(target)) { + throw new IllegalArgumentException("Type not supported: " + target); + } + if (target.isInstance(value)) { + return target.cast(value); + } + if (target == String.class) { + return (T) String.valueOf(value); + } + if (target == Boolean.class) { + if (value instanceof Number) { + return (T) (Boolean) (parseDouble(value) != 0); + } + return (T) Boolean.valueOf(value.toString()); + } + if (target == Integer.class) { + final Double number = parseDouble(value); + return (T) (Integer) number.intValue(); + } + if (target == Double.class) { + final Double number = parseDouble(value); + return (T) number; + } + return (T) Value.objectToValue(value); + } + + private static Double parseDouble(final Object value) { + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + return Double.parseDouble(String.valueOf(value)); + } + + private static void dispatchExposure( + final String flag, final ProviderEvaluation evaluation, final EvaluationContext context) { + final String allocationKey = allocationKey(evaluation); + final String variantKey = evaluation.getVariant(); + if (allocationKey == null || variantKey == null) { + return; + } + final ExposureEvent event = + new ExposureEvent( + System.currentTimeMillis(), + new datadog.trace.api.featureflag.exposure.Allocation(allocationKey), + new datadog.trace.api.featureflag.exposure.Flag(flag), + new datadog.trace.api.featureflag.exposure.Variant(variantKey), + new Subject(context.getTargetingKey(), flattenContext(context))); + + FeatureFlaggingGateway.dispatch(event); + } + + private static String allocationKey(final ProviderEvaluation resolution) { + final ImmutableMetadata meta = resolution.getFlagMetadata(); + return meta == null ? null : meta.getString("allocationKey"); + } + + static AbstractMap flattenContext(final EvaluationContext context) { + final Set keys = context.keySet(); + final HashMap result = new HashMap<>(); + final Set seen = new HashSet<>(); + for (final String key : keys) { + final Deque deque = new LinkedList<>(); + deque.push(new FlattenEntry(key, context.getValue(key))); + while (!deque.isEmpty()) { + final FlattenEntry entry = deque.pop(); + final Value value = entry.value; + if (value == null || seen.add(value)) { + if (value == null) { + result.put(entry.key, null); + } else if (value.isList()) { + final List list = value.asList(); + for (int i = 0; i < list.size(); i++) { + deque.push(new FlattenEntry(entry.key + "[" + i + "]", list.get(i))); + } + } else if (value.isStructure()) { + final Structure structure = value.asStructure(); + for (final String property : structure.keySet()) { + deque.push( + new FlattenEntry(entry.key + "." + property, structure.getValue(property))); + } + } else { + result.put(entry.key, context.convertValue(value)); + } + } + } + } + return result; + } + + @FunctionalInterface + private interface NumberComparator { + boolean compare(double a, double b); + } + + private static class FlattenEntry { + private final String key; + private final Value value; + + private FlattenEntry(final String key, final Value value) { + this.key = key; + this.value = value; + } + } +} diff --git a/products/openfeature/src/main/java/datadog/trace/api/openfeature/Evaluator.java b/products/openfeature/src/main/java/datadog/trace/api/openfeature/Evaluator.java new file mode 100644 index 00000000000..6ce3bac7b93 --- /dev/null +++ b/products/openfeature/src/main/java/datadog/trace/api/openfeature/Evaluator.java @@ -0,0 +1,15 @@ +package datadog.trace.api.openfeature; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ProviderEvaluation; +import java.util.concurrent.TimeUnit; + +interface Evaluator { + + boolean initialize(long timeout, TimeUnit timeUnit, EvaluationContext context) throws Exception; + + void shutdown(); + + ProviderEvaluation evaluate( + Class target, String key, T defaultValue, EvaluationContext context); +} diff --git a/products/openfeature/src/main/java/datadog/trace/api/openfeature/Provider.java b/products/openfeature/src/main/java/datadog/trace/api/openfeature/Provider.java new file mode 100644 index 00000000000..0b0faf38c1c --- /dev/null +++ b/products/openfeature/src/main/java/datadog/trace/api/openfeature/Provider.java @@ -0,0 +1,151 @@ +package datadog.trace.api.openfeature; + +import static java.util.concurrent.TimeUnit.SECONDS; + +import de.thetaphi.forbiddenapis.SuppressForbidden; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.EventProvider; +import dev.openfeature.sdk.Metadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEvent; +import dev.openfeature.sdk.ProviderEventDetails; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.FatalError; +import dev.openfeature.sdk.exceptions.OpenFeatureError; +import dev.openfeature.sdk.exceptions.ProviderNotReadyError; +import java.lang.reflect.Constructor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +public class Provider extends EventProvider implements Metadata { + + 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 volatile Evaluator evaluator; + private final Options options; + private final AtomicBoolean initialized = new AtomicBoolean(false); + + public Provider() { + this(DEFAULT_OPTIONS, null); + } + + public Provider(final Options options) { + this(options, null); + } + + Provider(final Options options, final Evaluator evaluator) { + this.options = options; + this.evaluator = evaluator; + } + + @Override + public void initialize(final EvaluationContext context) throws Exception { + try { + evaluator = buildEvaluator(); + final boolean init = evaluator.initialize(options.getTimeout(), options.getUnit(), context); + initialized.set(init); + if (!init) { + throw new ProviderNotReadyError( + "Provider timed-out while waiting for initial configuration"); + } + } catch (final OpenFeatureError e) { + throw e; + } catch (final Throwable e) { + throw new FatalError("Failed to initialize provider, is the tracer configured?", e); + } + } + + private void onConfigurationChange() { + if (initialized.getAndSet(true)) { + emit( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, + ProviderEventDetails.builder().message("New configuration received").build()); + } else { + emit( + ProviderEvent.PROVIDER_READY, + ProviderEventDetails.builder().message("Provider ready").build()); + } + } + + private Evaluator buildEvaluator() throws Exception { + if (evaluator != null) { + return evaluator; + } + final Class evaluatorClass = loadEvaluatorClass(); + final Constructor ctor = evaluatorClass.getConstructor(Runnable.class); + return (Evaluator) ctor.newInstance((Runnable) this::onConfigurationChange); + } + + @Override + public void shutdown() { + if (evaluator != null) { + evaluator.shutdown(); + } + } + + @Override + public Metadata getMetadata() { + return this; + } + + @Override + public String getName() { + return METADATA; + } + + @Override + public ProviderEvaluation getBooleanEvaluation( + final String key, final Boolean defaultValue, final EvaluationContext ctx) { + return evaluator.evaluate(Boolean.class, key, defaultValue, ctx); + } + + @Override + public ProviderEvaluation getStringEvaluation( + final String key, final String defaultValue, final EvaluationContext ctx) { + return evaluator.evaluate(String.class, key, defaultValue, ctx); + } + + @Override + public ProviderEvaluation getIntegerEvaluation( + final String key, final Integer defaultValue, final EvaluationContext ctx) { + return evaluator.evaluate(Integer.class, key, defaultValue, ctx); + } + + @Override + public ProviderEvaluation getDoubleEvaluation( + final String key, final Double defaultValue, final EvaluationContext ctx) { + return evaluator.evaluate(Double.class, key, defaultValue, ctx); + } + + @Override + public ProviderEvaluation getObjectEvaluation( + final String key, final Value defaultValue, final EvaluationContext ctx) { + return evaluator.evaluate(Value.class, key, defaultValue, ctx); + } + + @SuppressForbidden // Class#forName(String) used to lazy load internal-api dependencies + protected Class loadEvaluatorClass() throws ClassNotFoundException { + return Class.forName(EVALUATOR_IMPL); + } + + public static class Options { + + private long timeout; + private TimeUnit unit; + + public Options initTimeout(final long timeout, final TimeUnit unit) { + this.timeout = timeout; + this.unit = unit; + return this; + } + + public long getTimeout() { + return timeout; + } + + public TimeUnit getUnit() { + return unit; + } + } +} diff --git a/products/openfeature/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java b/products/openfeature/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java new file mode 100644 index 00000000000..b1804f24a39 --- /dev/null +++ b/products/openfeature/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java @@ -0,0 +1,1223 @@ +package datadog.trace.api.openfeature; + +import static dev.openfeature.sdk.ErrorCode.FLAG_NOT_FOUND; +import static dev.openfeature.sdk.ErrorCode.TARGETING_KEY_MISSING; +import static dev.openfeature.sdk.Reason.DEFAULT; +import static dev.openfeature.sdk.Reason.DISABLED; +import static dev.openfeature.sdk.Reason.ERROR; +import static dev.openfeature.sdk.Reason.TARGETING_MATCH; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasEntry; +import static org.hamcrest.Matchers.oneOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +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; +import datadog.trace.api.featureflag.ufc.v1.Flag; +import datadog.trace.api.featureflag.ufc.v1.Rule; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import datadog.trace.api.featureflag.ufc.v1.Shard; +import datadog.trace.api.featureflag.ufc.v1.ShardRange; +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.util.TestCase; +import datadog.trace.api.openfeature.util.TestCase.Result; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Value; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class DDEvaluatorTest { + + @Captor private ArgumentCaptor exposureEventCaptor; + + private FeatureFlaggingGateway.ExposureListener exposureListener; + + @BeforeEach + public void setup() { + exposureListener = mock(FeatureFlaggingGateway.ExposureListener.class); + FeatureFlaggingGateway.addExposureListener(exposureListener); + } + + @AfterEach + public void tearDown() { + FeatureFlaggingGateway.removeExposureListener(exposureListener); + } + + private static Arguments[] dateParsingTestCases() { + return new Arguments[] { + // Valid ISO 8601 formats + Arguments.of("2023-01-01T00:00:00Z", new Date(1672531200000L)), // 2023-01-01 00:00:00 UTC + Arguments.of("2023-12-31T23:59:59Z", new Date(1704067199000L)), // 2023-12-31 23:59:59 UTC + Arguments.of("2024-02-29T12:00:00Z", new Date(1709208000000L)), // Leap year date + Arguments.of("2023-01-01T00:00:00.000Z", new Date(1672531200000L)), // With milliseconds + Arguments.of("2023-06-15T14:30:45.123Z", new Date(1686839445123L)), // With milliseconds + + // Non supported formats should return null + Arguments.of("2023-01-01T01:00:00+01:00", null), // UTC+1 + Arguments.of("2023-01-01T00:00:00-05:00", null), // UTC-5 + Arguments.of("2023-01-01", null), // Date only + Arguments.of("invalid-date", null), + Arguments.of("", null), + Arguments.of("not-a-date", null), + Arguments.of("2023/01/01T00:00:00Z", null), // Wrong separator + + // Null input + Arguments.of(null, null) + }; + } + + @MethodSource("dateParsingTestCases") + @ParameterizedTest + public void testDateParsing(final String date, final Object expected) { + final Date value = DDEvaluator.parseDate(date); + assertThat(value, equalTo(expected)); + } + + private static Arguments[] valueMappingTestCases() { + return new Arguments[] { + // String mappings + Arguments.of(String.class, "hello", "hello"), + Arguments.of(String.class, 123, "123"), + Arguments.of(String.class, true, "true"), + Arguments.of(String.class, 3.14, "3.14"), + Arguments.of(String.class, null, null), + + // Boolean mappings + Arguments.of(Boolean.class, true, true), + Arguments.of(Boolean.class, false, false), + Arguments.of(Boolean.class, "true", true), + Arguments.of(Boolean.class, "false", false), + Arguments.of(Boolean.class, "TRUE", true), + Arguments.of(Boolean.class, "FALSE", false), + Arguments.of(Boolean.class, 1, true), + Arguments.of(Boolean.class, 0, false), + Arguments.of(Boolean.class, null, null), + + // Integer mappings + Arguments.of(Integer.class, 42, 42), + Arguments.of(Integer.class, "42", 42), + Arguments.of(Integer.class, 3.14, 3), + Arguments.of(Integer.class, "3.14", 3), + Arguments.of(Integer.class, null, null), + + // Double mappings + Arguments.of(Double.class, 3.14, 3.14), + Arguments.of(Double.class, "3.14", 3.14), + Arguments.of(Double.class, 42, 42.0), + Arguments.of(Double.class, "42", 42.0), + Arguments.of(Double.class, null, null), + + // Value mappings (OpenFeature Value objects) + Arguments.of(Value.class, "hello", Value.objectToValue("hello")), + Arguments.of(Value.class, 42, Value.objectToValue(42)), + Arguments.of(Value.class, 3.14, Value.objectToValue(3.14)), + Arguments.of(Value.class, true, Value.objectToValue(true)), + Arguments.of(Value.class, null, null), + + // Unsupported + Arguments.of(Date.class, "21-12-2023", IllegalArgumentException.class), + }; + } + + @ParameterizedTest + @MethodSource("valueMappingTestCases") + public void testValueMapping(final Class target, final Object value, final Object expected) { + if (expected == IllegalArgumentException.class) { + assertThrows(IllegalArgumentException.class, () -> DDEvaluator.mapValue(target, value)); + } else { + final Object result = DDEvaluator.mapValue(target, value); + assertThat(result, equalTo(expected)); + } + } + + @Test + public void testEvaluateNoConfig() { + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final ProviderEvaluation details = + evaluator.evaluate(Integer.class, "test", 23, mock(EvaluationContext.class)); + assertThat(details.getValue(), equalTo(23)); + assertThat(details.getReason(), equalTo(ERROR.name())); + assertThat(details.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); + } + + @Test + public void testEvaluateNoContext() { + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + evaluator.accept(mock(ServerConfiguration.class)); + final ProviderEvaluation details = evaluator.evaluate(Integer.class, "test", 23, null); + assertThat(details.getValue(), equalTo(23)); + assertThat(details.getReason(), equalTo(ERROR.name())); + assertThat(details.getErrorCode(), equalTo(ErrorCode.INVALID_CONTEXT)); + } + + @Test + public void testNoAllocations() { + final Map flags = new HashMap<>(); + 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("", "", null, flags)); + + final EvaluationContext ctx = new MutableContext("target").setTargetingKey("allocation"); + + ProviderEvaluation details = evaluator.evaluate(Integer.class, "null-allocation", 23, ctx); + assertThat(details.getValue(), equalTo(23)); + assertThat(details.getReason(), equalTo(ERROR.name())); + assertThat(details.getErrorCode(), equalTo(ErrorCode.GENERAL)); + + details = evaluator.evaluate(Integer.class, "empty-allocation", 23, ctx); + assertThat(details.getValue(), equalTo(23)); + assertThat(details.getReason(), equalTo(ERROR.name())); + assertThat(details.getErrorCode(), equalTo(ErrorCode.GENERAL)); + } + + private static Arguments[] flatteningTestCases() { + final List arguments = new ArrayList<>(); + arguments.add(Arguments.of(emptyMap(), emptyMap())); + arguments.add( + Arguments.of( + mapOf("integer", 1, "double", 23D, "boolean", true, "string", "string", "null", null), + mapOf("integer", 1, "double", 23D, "boolean", true, "string", "string", "null", null))); + arguments.add( + Arguments.of( + mapOf("list", asList(1, 2, singletonList(4))), + mapOf("list[0]", 1, "list[1]", 2, "list[2][0]", 4))); + arguments.add( + Arguments.of( + mapOf("map", mapOf("key1", 1, "key2", 2, "key3", mapOf("key4", 4))), + mapOf("map.key1", 1, "map.key2", 2, "map.key3.key4", 4))); + return arguments.toArray(new Arguments[0]); + } + + @MethodSource("flatteningTestCases") + @ParameterizedTest + public void testFlattening( + final Map attributes, final Map expected) { + final EvaluationContext context = + new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); + final Map result = DDEvaluator.flattenContext(context); + + assertThat(result.size(), equalTo(expected.size())); + for (final Map.Entry entry : expected.entrySet()) { + assertThat(result, hasEntry(entry.getKey(), entry.getValue())); + } + } + + private static List> evaluateTestCases() { + return Arrays.asList( + new TestCase<>("default") + .flag("simple-string") + .result(new Result<>("default").reason(ERROR.name()).errorCode(TARGETING_KEY_MISSING)), + new TestCase<>("default") + .flag("non-existent-flag") + .targetingKey("user-123") + .result(new Result<>("default").reason(ERROR.name()).errorCode(FLAG_NOT_FOUND)), + new TestCase<>("default") + .flag("disabled-flag") + .targetingKey("user-123") + .result(new Result<>("default").reason(DISABLED.name())), + new TestCase<>("default") + .flag("simple-string") + .targetingKey("user-123") + .result(new Result<>("test-value").reason(TARGETING_MATCH.name()).variant("on")), + new TestCase<>(false) + .flag("boolean-flag") + .targetingKey("user-123") + .result(new Result<>(true).reason(TARGETING_MATCH.name()).variant("enabled")), + new TestCase<>(0) + .flag("integer-flag") + .targetingKey("user-123") + .result(new Result<>(42).reason(TARGETING_MATCH.name()).variant("forty-two")), + new TestCase<>("default") + .flag("rule-based-flag") + .targetingKey("user-premium") + .context("email", "john@company.com") + .result(new Result<>("premium").reason(TARGETING_MATCH.name()).variant("premium")), + new TestCase<>("default") + .flag("rule-based-flag") + .targetingKey("user-basic") + .context("email", "john@gmail.com") + .result(new Result<>("basic").reason(TARGETING_MATCH.name()).variant("basic")), + new TestCase<>("default") + .flag("numeric-rule-flag") + .targetingKey("user-vip") + .context("score", 850) + .result(new Result<>("vip").reason(TARGETING_MATCH.name()).variant("vip")), + new TestCase<>("default") + .flag("null-check-flag") + .targetingKey("user-no-beta") + .result(new Result<>("no-beta").reason(TARGETING_MATCH.name()).variant("no-beta")), + new TestCase<>("default") + .flag("region-flag") + .targetingKey("user-regional") + .context("region", "us-east-1") + .result(new Result<>("regional").reason(TARGETING_MATCH.name()).variant("regional")), + new TestCase<>("default") + .flag("time-based-flag") + .targetingKey("user-regional") + .context("region", "us-east-1") + .result(new Result<>("default").reason(DEFAULT.name())), + new TestCase<>("default") + .flag("shard-flag") + .targetingKey("user-shard-test") + .result( + new Result<>("default") + // Result depends on shard calculation - either match or default + .reason(TARGETING_MATCH.name(), DEFAULT.name())), + new TestCase<>(0) + .flag("string-number-flag") + .targetingKey("user-123") + .result(new Result<>(123).reason(TARGETING_MATCH.name()).variant("string-num")), + new TestCase<>("default") + .flag("broken-flag") + .targetingKey("user-123") + .result(new Result<>("default").reason(ERROR.name()).errorCode(ErrorCode.GENERAL)), + new TestCase<>("default") + .flag("lt-flag") + .targetingKey("user-123") + .context("score", 750) + .result(new Result<>("low-score").reason(TARGETING_MATCH.name()).variant("low")), + new TestCase<>("default") + .flag("lte-flag") + .targetingKey("user-123") + .context("score", 800) + .result(new Result<>("medium-score").reason(TARGETING_MATCH.name()).variant("medium")), + new TestCase<>("default") + .flag("gt-flag") + .targetingKey("user-123") + .context("score", 950) + .result(new Result<>("high-score").reason(TARGETING_MATCH.name()).variant("high")), + new TestCase<>("default") + .flag("not-matches-flag") + .targetingKey("user-123") + .context("email", "user@yahoo.com") + .result(new Result<>("external").reason(TARGETING_MATCH.name()).variant("external")), + new TestCase<>("default") + .flag("not-one-of-flag") + .targetingKey("user-123") + .context("region", "ap-south-1") + .result(new Result<>("other-region").reason(TARGETING_MATCH.name()).variant("other")), + new TestCase<>("default") + .flag("double-equals-flag") + .targetingKey("user-123") + .context("rate", 3.14159) + .result(new Result<>("pi-value").reason(TARGETING_MATCH.name()).variant("pi")), + new TestCase<>("default") + .flag("nested-attr-flag") + .targetingKey("user-123") + .context("user.profile.level", "premium") + .result(new Result<>("premium-user").reason(TARGETING_MATCH.name()).variant("premium")), + new TestCase<>("default") + .flag("lt-flag") + .targetingKey("user-123") + .context("score", "not-a-number") + .result( + new Result<>("default").reason(ERROR.name()).errorCode(ErrorCode.TYPE_MISMATCH)), + new TestCase<>("default") + .flag("exposure-flag") + .targetingKey("user-123") + .result( + new Result<>("tracked-value") + .reason(TARGETING_MATCH.name()) + .variant("tracked") + .flagMetadata("allocationKey", "exposure-alloc") + .flagMetadata("doLog", true)), + new TestCase<>("default") + .flag("exposure-logging-flag") + .targetingKey("user-exposure") + .context("feature", "premium") + .result( + new Result<>("logged-value") + .reason(TARGETING_MATCH.name()) + .variant("logged") + .flagMetadata("allocationKey", "logged-alloc") + .flagMetadata("doLog", true)), + new TestCase<>("default") + .flag("double-comparison-flag") + .targetingKey("user-123") + .context("score", 3.14159) + .result(new Result<>("exact-match").reason(TARGETING_MATCH.name()).variant("exact")), + new TestCase<>("default") + .flag("numeric-one-of-flag") + .targetingKey("user-123") + .context("score", 3.14159) + .result( + new Result<>("numeric-matched") + .reason(TARGETING_MATCH.name()) + .variant("numeric-match")), + new TestCase<>("default") + .flag("numeric-not-one-of-flag") + .targetingKey("user-123") + .context("score", 42.0) + .result(new Result<>("not-in-set").reason(TARGETING_MATCH.name()).variant("excluded")), + new TestCase<>("default") + .flag("is-null-false-flag") + .targetingKey("user-123") + .context("attr", "value") + .result(new Result<>("not-null").reason(TARGETING_MATCH.name()).variant("not-null")), + new TestCase<>("default") + .flag("is-null-non-boolean-flag") + .targetingKey("user-123") + .result( + new Result<>("null-match").reason(TARGETING_MATCH.name()).variant("null-match")), + new TestCase<>("default") + .flag("null-attribute-flag") + .targetingKey("user-123") + .result(new Result<>("default").reason(DEFAULT.name())), + new TestCase<>("default") + .flag("not-matches-positive-flag") + .targetingKey("user-123") + .context("email", "user@gmail.com") + .result( + new Result<>("external-email").reason(TARGETING_MATCH.name()).variant("external")), + new TestCase<>("default") + .flag("not-one-of-positive-flag") + .targetingKey("user-123") + .context("region", "ap-south-1") + .result(new Result<>("other-region").reason(TARGETING_MATCH.name()).variant("other")), + new TestCase<>("default") + .flag("false-numeric-comparisons-flag") + .targetingKey("user-123") + .context("score", 750) + .result(new Result<>("default").reason(DEFAULT.name())), + new TestCase<>("default") + .flag("empty-splits-flag") + .targetingKey("user-123") + .result(new Result<>("default").reason(DEFAULT.name())), + new TestCase<>("default") + .flag("empty-conditions-flag") + .targetingKey("user-123") + .result(new Result<>("default").reason(DEFAULT.name())), + new TestCase<>("default") + .flag("shard-matching-flag") + .targetingKey("specific-key-that-matches-shard") + .result( + new Result<>("shard-matched").reason(TARGETING_MATCH.name()).variant("matched")), + new TestCase<>("default") + .flag("future-allocation-flag") + .targetingKey("user-123") + .result(new Result<>("default").reason(DEFAULT.name())), + new TestCase<>("default") + .flag("id-attribute-flag") + .targetingKey("user-special-id") + .result(new Result<>("id-resolved").reason(TARGETING_MATCH.name()).variant("id-match")), + new TestCase<>("default") + .flag("non-iterable-condition-flag") + .targetingKey("user-123") + .context("attr", "test-value") + .result(new Result<>("default").reason(DEFAULT.name())), + new TestCase<>("default") + .flag("gt-false-flag") + .targetingKey("user-123") + .context("score", 500) + .result(new Result<>("default").reason(DEFAULT.name())), + new TestCase<>("default") + .flag("lte-false-flag") + .targetingKey("user-123") + .context("score", 600) + .result(new Result<>("default").reason(DEFAULT.name())), + new TestCase<>("default") + .flag("lt-false-flag") + .targetingKey("user-123") + .context("score", 700) + .result(new Result<>("default").reason(DEFAULT.name())), + new TestCase<>("default") + .flag("not-matches-false-flag") + .targetingKey("user-123") + .context("email", "user@company.com") + .result(new Result<>("default").reason(DEFAULT.name())), + new TestCase<>("default") + .flag("not-one-of-false-flag") + .targetingKey("user-123") + .context("region", "us-east-1") + .result(new Result<>("default").reason(DEFAULT.name())), + new TestCase<>("default") + .flag("null-context-values-flag") + .targetingKey("user-123") + .context("nullAttr", (String) null) + .result( + new Result<>("null-handled") + .reason(TARGETING_MATCH.name()) + .variant("null-variant"))); + } + + @MethodSource("evaluateTestCases") + @ParameterizedTest + public void testEvaluate(final TestCase testCase) { + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + evaluator.accept(createTestConfiguration()); + final ProviderEvaluation details = + evaluator.evaluate(testCase.type, testCase.flag, testCase.defaultValue, testCase.context); + final Result expected = testCase.result; + assertThat(details.getValue(), equalTo(expected.value)); + assertThat(details.getReason(), oneOf(expected.reason)); + assertThat(details.getVariant(), equalTo(expected.variant)); + assertThat(details.getErrorCode(), equalTo(expected.errorCode)); + assertThat(details.getErrorCode(), equalTo(expected.errorCode)); + final String expectedAllocation = (String) expected.flagMetadata.get("allocationKey"); + if (expectedAllocation != null) { + assertThat(details.getFlagMetadata().getString("allocationKey"), equalTo(expectedAllocation)); + } + if (shouldDispatchExposure(expected)) { + verify(exposureListener, times(1)).accept(exposureEventCaptor.capture()); + final ExposureEvent capturedEvent = exposureEventCaptor.getValue(); + assertThat(capturedEvent.flag.key, equalTo(testCase.flag)); + assertThat(capturedEvent.allocation.key, equalTo(expectedAllocation)); + assertThat(capturedEvent.variant.key, equalTo(testCase.result.variant)); + assertThat(capturedEvent.subject.id, equalTo(testCase.context.getTargetingKey())); + for (final Map.Entry entry : testCase.context.asObjectMap().entrySet()) { + assertThat(capturedEvent.subject.attributes, hasEntry(entry.getKey(), entry.getValue())); + } + } else { + verify(exposureListener, times(0)).accept(any(ExposureEvent.class)); + } + } + + private static boolean shouldDispatchExposure(final Result result) { + final Boolean doLog = (Boolean) result.flagMetadata.get("doLog"); + return doLog != null && doLog; + } + + private ServerConfiguration createTestConfiguration() { + final Map flags = new HashMap<>(); + flags.put( + "simple-string", createSimpleFlag("simple-string", ValueType.STRING, "test-value", "on")); + flags.put("boolean-flag", createSimpleFlag("boolean-flag", ValueType.BOOLEAN, true, "enabled")); + flags.put("integer-flag", createSimpleFlag("integer-flag", ValueType.INTEGER, 42, "forty-two")); + flags.put("double-flag", createSimpleFlag("double-flag", ValueType.NUMERIC, 3.14, "pi")); + flags.put( + "string-number-flag", + createSimpleFlag("string-number-flag", ValueType.STRING, "123", "string-num")); + flags.put("disabled-flag", new Flag("disabled-flag", false, ValueType.BOOLEAN, null, null)); + flags.put("rule-based-flag", createRuleBasedFlag()); + flags.put("numeric-rule-flag", createNumericRuleFlag()); + flags.put("null-check-flag", createNullCheckFlag()); + flags.put("region-flag", createOneOfRuleFlag()); + flags.put("time-based-flag", createTimeBasedFlag()); + flags.put("shard-flag", createShardBasedFlag()); + flags.put("broken-flag", createBrokenFlag()); + flags.put("lt-flag", createLessThanFlag()); + flags.put("lte-flag", createLessThanOrEqualFlag()); + flags.put("gt-flag", createGreaterThanFlag()); + flags.put("not-matches-flag", createNotMatchesFlag()); + flags.put("not-one-of-flag", createNotOneOfFlag()); + flags.put("double-equals-flag", createDoubleEqualsFlag()); + flags.put("nested-attr-flag", createNestedAttributeFlag()); + flags.put("exposure-flag", createExposureFlag()); + flags.put("exposure-logging-flag", createExposureLoggingFlag()); + flags.put("double-comparison-flag", createDoubleComparisonFlag()); + flags.put("numeric-one-of-flag", createNumericOneOfFlag()); + flags.put("numeric-not-one-of-flag", createNumericNotOneOfFlag()); + flags.put("is-null-false-flag", createIsNullFalseFlag()); + flags.put("is-null-non-boolean-flag", createIsNullNonBooleanFlag()); + flags.put("null-attribute-flag", createNullAttributeFlag()); + flags.put("not-matches-positive-flag", createNotMatchesPositiveFlag()); + flags.put("not-one-of-positive-flag", createNotOneOfPositiveFlag()); + flags.put("false-numeric-comparisons-flag", createFalseNumericComparisonsFlag()); + flags.put("empty-splits-flag", createEmptySplitsFlag()); + flags.put("empty-conditions-flag", createEmptyConditionsFlag()); + flags.put("shard-matching-flag", createShardMatchingFlag()); + flags.put("future-allocation-flag", createFutureAllocationFlag()); + flags.put("id-attribute-flag", createIdAttributeFlag()); + flags.put("non-iterable-condition-flag", createNonIterableConditionFlag()); + flags.put("gt-false-flag", createGtFalseFlag()); + flags.put("lte-false-flag", createLteFalseFlag()); + flags.put("lt-false-flag", createLtFalseFlag()); + flags.put("not-matches-false-flag", createNotMatchesFalseFlag()); + flags.put("not-one-of-false-flag", createNotOneOfFalseFlag()); + flags.put("null-context-values-flag", createNullContextValuesFlag()); + return new ServerConfiguration(null, null, null, flags); + } + + private Flag createSimpleFlag(String key, ValueType type, Object value, String variantKey) { + final Map variants = new HashMap<>(); + variants.put(variantKey, new Variant(variantKey, value)); + final List splits = singletonList(new Split(emptyList(), variantKey, null)); + final List allocations = + singletonList(new Allocation("alloc1", null, null, null, splits, false)); + return new Flag(key, true, type, variants, allocations); + } + + private Flag createRuleBasedFlag() { + final Map variants = new HashMap<>(); + variants.put("premium", new Variant("premium", "premium")); + variants.put("basic", new Variant("basic", "basic")); + + // Rule: email MATCHES @company.com$ -> premium + final List premiumConditions = + singletonList( + new ConditionConfiguration(ConditionOperator.MATCHES, "email", "@company\\.com$")); + final List premiumRules = singletonList(new Rule(premiumConditions)); + final List premiumSplits = singletonList(new Split(emptyList(), "premium", null)); + final Allocation premiumAllocation = + new Allocation("premium-alloc", premiumRules, null, null, premiumSplits, false); + + // Fallback allocation for basic + final List basicSplits = singletonList(new Split(emptyList(), "basic", null)); + final Allocation basicAllocation = + new Allocation("basic-alloc", null, null, null, basicSplits, false); + + final List allocations = asList(premiumAllocation, basicAllocation); + + return new Flag("rule-based-flag", true, ValueType.STRING, variants, allocations); + } + + private Flag createNumericRuleFlag() { + final Map variants = new HashMap<>(); + variants.put("vip", new Variant("vip", "vip")); + variants.put("regular", new Variant("regular", "regular")); + + // Rule: score >= 800 -> vip + final List vipConditions = + singletonList(new ConditionConfiguration(ConditionOperator.GTE, "score", 800)); + final List vipRules = singletonList(new Rule(vipConditions)); + final List vipSplits = singletonList(new Split(emptyList(), "vip", null)); + final Allocation vipAllocation = + new Allocation("vip-alloc", vipRules, null, null, vipSplits, false); + + // Fallback + final List regularSplits = singletonList(new Split(emptyList(), "regular", null)); + final Allocation regularAllocation = + new Allocation("regular-alloc", null, null, null, regularSplits, false); + + return new Flag( + "numeric-rule-flag", + true, + ValueType.STRING, + variants, + asList(vipAllocation, regularAllocation)); + } + + private Flag createNullCheckFlag() { + final Map variants = new HashMap<>(); + variants.put("no-beta", new Variant("no-beta", "no-beta")); + variants.put("has-beta", new Variant("has-beta", "has-beta")); + + // Rule: beta_feature IS_NULL (true) -> no-beta + final List noBetaConditions = + singletonList(new ConditionConfiguration(ConditionOperator.IS_NULL, "beta_feature", true)); + final List noBetaRules = singletonList(new Rule(noBetaConditions)); + final List noBetaSplits = singletonList(new Split(emptyList(), "no-beta", null)); + final Allocation noBetaAllocation = + new Allocation("no-beta-alloc", noBetaRules, null, null, noBetaSplits, false); + + // Fallback + final List hasBetaSplits = singletonList(new Split(emptyList(), "has-beta", null)); + final Allocation hasBetaAllocation = + new Allocation("has-beta-alloc", null, null, null, hasBetaSplits, false); + + return new Flag( + "null-check-flag", + true, + ValueType.STRING, + variants, + asList(noBetaAllocation, hasBetaAllocation)); + } + + private Flag createOneOfRuleFlag() { + final Map variants = new HashMap<>(); + variants.put("regional", new Variant("regional", "regional")); + variants.put("global", new Variant("global", "global")); + + // Rule: region ONE_OF [us-east-1, us-west-2, eu-west-1] -> regional + final List allowedRegions = asList("us-east-1", "us-west-2", "eu-west-1"); + final List regionalConditions = + singletonList( + new ConditionConfiguration(ConditionOperator.ONE_OF, "region", allowedRegions)); + final List regionalRules = singletonList(new Rule(regionalConditions)); + final List regionalSplits = singletonList(new Split(emptyList(), "regional", null)); + final Allocation regionalAllocation = + new Allocation("regional-alloc", regionalRules, null, null, regionalSplits, false); + + // Fallback + final List globalSplits = singletonList(new Split(emptyList(), "global", null)); + final Allocation globalAllocation = + new Allocation("global-alloc", null, null, null, globalSplits, false); + + return new Flag( + "region-flag", + true, + ValueType.STRING, + variants, + asList(regionalAllocation, globalAllocation)); + } + + private Flag createTimeBasedFlag() { + final Map variants = new HashMap<>(); + variants.put("time-limited", new Variant("time-limited", "time-limited")); + + final List splits = singletonList(new Split(emptyList(), "time-limited", null)); + + // Allocation that ended in 2022 (should be inactive) + final List allocations = + singletonList( + new Allocation( + "time-alloc", null, "2022-01-01T00:00:00Z", "2022-12-31T23:59:59Z", splits, false)); + + return new Flag("time-based-flag", true, ValueType.STRING, variants, allocations); + } + + private Flag createShardBasedFlag() { + final Map variants = new HashMap<>(); + variants.put("shard-variant", new Variant("shard-variant", "shard-value")); + + // Create a shard that includes some range + final List ranges = singletonList(new ShardRange(0, 50)); // 0-49 out of 100 + final List shards = singletonList(new Shard("test-salt", ranges, 100)); + + final List splits = singletonList(new Split(shards, "shard-variant", null)); + + final List allocations = + singletonList(new Allocation("shard-alloc", null, null, null, splits, false)); + + return new Flag("shard-flag", true, ValueType.STRING, variants, allocations); + } + + private Flag createBrokenFlag() { + // Create a flag with missing variant + final Map variants = new HashMap<>(); + variants.put("existing", new Variant("existing", "value")); + + final List splits = singletonList(new Split(emptyList(), "missing-variant", null)); + + final List allocations = + singletonList(new Allocation("alloc1", null, null, null, splits, false)); + + return new Flag("broken-flag", true, ValueType.STRING, variants, allocations); + } + + private Flag createComparisonFlag( + String flagKey, + String allocKey, + String variantKey, + String variantValue, + ConditionOperator operator, + String attribute, + Object threshold) { + final Map variants = new HashMap<>(); + variants.put(variantKey, new Variant(variantKey, variantValue)); + + final List conditions = + singletonList(new ConditionConfiguration(operator, attribute, threshold)); + final List rules = singletonList(new Rule(conditions)); + final List splits = singletonList(new Split(emptyList(), variantKey, null)); + final Allocation allocation = new Allocation(allocKey, rules, null, null, splits, false); + + return new Flag(flagKey, true, ValueType.STRING, variants, singletonList(allocation)); + } + + private Flag createLessThanFlag() { + return createComparisonFlag( + "lt-flag", "low-alloc", "low", "low-score", ConditionOperator.LT, "score", 800); + } + + private Flag createLessThanOrEqualFlag() { + return createComparisonFlag( + "lte-flag", "medium-alloc", "medium", "medium-score", ConditionOperator.LTE, "score", 800); + } + + private Flag createGreaterThanFlag() { + return createComparisonFlag( + "gt-flag", "high-alloc", "high", "high-score", ConditionOperator.GT, "score", 900); + } + + private Flag createNotOperatorFlag( + String flagKey, + String allocKey, + String variantKey, + String variantValue, + ConditionOperator operator, + String attribute, + Object value) { + final Map variants = new HashMap<>(); + variants.put(variantKey, new Variant(variantKey, variantValue)); + + final List conditions = + singletonList(new ConditionConfiguration(operator, attribute, value)); + final List rules = singletonList(new Rule(conditions)); + final List splits = singletonList(new Split(emptyList(), variantKey, null)); + final Allocation allocation = new Allocation(allocKey, rules, null, null, splits, false); + + return new Flag(flagKey, true, ValueType.STRING, variants, singletonList(allocation)); + } + + private Flag createNotMatchesFlag() { + return createNotOperatorFlag( + "not-matches-flag", + "external-alloc", + "external", + "external", + ConditionOperator.NOT_MATCHES, + "email", + "@company\\.com$"); + } + + private Flag createNotOneOfFlag() { + final List disallowedRegions = asList("us-east-1", "us-west-2", "eu-west-1"); + return createNotOperatorFlag( + "not-one-of-flag", + "other-alloc", + "other", + "other-region", + ConditionOperator.NOT_ONE_OF, + "region", + disallowedRegions); + } + + private Flag createDoubleEqualsFlag() { + final Map variants = new HashMap<>(); + variants.put("pi", new Variant("pi", "pi-value")); + + // This will test the double comparison in valuesEqual - match exact double value + final List piConditions = + singletonList(new ConditionConfiguration(ConditionOperator.MATCHES, "rate", "3.14159")); + final List piRules = singletonList(new Rule(piConditions)); + final List piSplits = singletonList(new Split(emptyList(), "pi", null)); + final Allocation piAllocation = + new Allocation("pi-alloc", piRules, null, null, piSplits, false); + + return new Flag( + "double-equals-flag", true, ValueType.STRING, variants, singletonList(piAllocation)); + } + + private Flag createNestedAttributeFlag() { + final Map variants = new HashMap<>(); + variants.put("premium", new Variant("premium", "premium-user")); + + // Rule: user.profile.level MATCHES premium -> premium + final List premiumConditions = + singletonList( + new ConditionConfiguration(ConditionOperator.MATCHES, "user.profile.level", "premium")); + final List premiumRules = singletonList(new Rule(premiumConditions)); + final List premiumSplits = singletonList(new Split(emptyList(), "premium", null)); + final Allocation premiumAllocation = + new Allocation("premium-nested-alloc", premiumRules, null, null, premiumSplits, false); + + return new Flag( + "nested-attr-flag", true, ValueType.STRING, variants, singletonList(premiumAllocation)); + } + + private Flag createExposureFlag() { + final Map variants = new HashMap<>(); + variants.put("tracked", new Variant("tracked", "tracked-value")); + + final List splits = singletonList(new Split(emptyList(), "tracked", null)); + // Create allocation with doLog=true to trigger exposure logging + final List allocations = + singletonList(new Allocation("exposure-alloc", null, null, null, splits, true)); + + return new Flag("exposure-flag", true, ValueType.STRING, variants, allocations); + } + + private Flag createDoubleComparisonFlag() { + final Map variants = new HashMap<>(); + variants.put("exact", new Variant("exact", "exact-match")); + + // This flag uses numeric comparison that will trigger the double comparison lambda + final List exactConditions = + singletonList(new ConditionConfiguration(ConditionOperator.LTE, "score", 3.14159)); + final List exactRules = singletonList(new Rule(exactConditions)); + final List exactSplits = singletonList(new Split(emptyList(), "exact", null)); + final Allocation exactAllocation = + new Allocation("exact-alloc", exactRules, null, null, exactSplits, false); + + return new Flag( + "double-comparison-flag", true, ValueType.STRING, variants, singletonList(exactAllocation)); + } + + private Flag createExposureLoggingFlag() { + final Map variants = new HashMap<>(); + variants.put("logged", new Variant("logged", "logged-value")); + + // Rule: feature MATCHES premium -> logged + final List loggedConditions = + singletonList(new ConditionConfiguration(ConditionOperator.MATCHES, "feature", "premium")); + final List loggedRules = singletonList(new Rule(loggedConditions)); + final List loggedSplits = singletonList(new Split(emptyList(), "logged", null)); + // Create allocation with doLog=true to trigger exposure logging and allocationKey method + final Allocation loggedAllocation = + new Allocation("logged-alloc", loggedRules, null, null, loggedSplits, true); + + return new Flag( + "exposure-logging-flag", true, ValueType.STRING, variants, singletonList(loggedAllocation)); + } + + private Flag createNumericOneOfFlag() { + final Map variants = new HashMap<>(); + variants.put("numeric-match", new Variant("numeric-match", "numeric-matched")); + + // Rule: score ONE_OF [3.14159, 2.71828] -> numeric-match + // This will trigger valuesEqual with numeric comparison via lambda$valuesEqual$4 + final List numericValues = asList(3.14159, 2.71828); + final List numericConditions = + singletonList(new ConditionConfiguration(ConditionOperator.ONE_OF, "score", numericValues)); + final List numericRules = singletonList(new Rule(numericConditions)); + final List numericSplits = singletonList(new Split(emptyList(), "numeric-match", null)); + final Allocation numericAllocation = + new Allocation("numeric-alloc", numericRules, null, null, numericSplits, false); + + return new Flag( + "numeric-one-of-flag", true, ValueType.STRING, variants, singletonList(numericAllocation)); + } + + private Flag createNumericNotOneOfFlag() { + final Map variants = new HashMap<>(); + variants.put("excluded", new Variant("excluded", "not-in-set")); + + // Rule: score NOT_ONE_OF [1.0, 2.0, 3.0] -> excluded + // This will trigger valuesEqual with numeric comparison via lambda$valuesEqual$4 + final List excludedValues = asList(1.0, 2.0, 3.0); + final List excludedConditions = + singletonList( + new ConditionConfiguration(ConditionOperator.NOT_ONE_OF, "score", excludedValues)); + final List excludedRules = singletonList(new Rule(excludedConditions)); + final List excludedSplits = singletonList(new Split(emptyList(), "excluded", null)); + final Allocation excludedAllocation = + new Allocation("excluded-alloc", excludedRules, null, null, excludedSplits, false); + + return new Flag( + "numeric-not-one-of-flag", + true, + ValueType.STRING, + variants, + singletonList(excludedAllocation)); + } + + private Flag createIsNullFalseFlag() { + final Map variants = new HashMap<>(); + variants.put("not-null", new Variant("not-null", "not-null")); + + // Rule: attr IS_NULL false -> not-null (checks if attr is NOT null) + final List notNullConditions = + singletonList(new ConditionConfiguration(ConditionOperator.IS_NULL, "attr", false)); + final List notNullRules = singletonList(new Rule(notNullConditions)); + final List notNullSplits = singletonList(new Split(emptyList(), "not-null", null)); + final Allocation notNullAllocation = + new Allocation("not-null-alloc", notNullRules, null, null, notNullSplits, false); + + return new Flag( + "is-null-false-flag", true, ValueType.STRING, variants, singletonList(notNullAllocation)); + } + + private Flag createIsNullNonBooleanFlag() { + final Map variants = new HashMap<>(); + variants.put("null-match", new Variant("null-match", "null-match")); + + // Rule: missing_attr IS_NULL "string" -> null-match (non-boolean expectedNull value) + final List nullConditions = + singletonList( + new ConditionConfiguration(ConditionOperator.IS_NULL, "missing_attr", "string")); + final List nullRules = singletonList(new Rule(nullConditions)); + final List nullSplits = singletonList(new Split(emptyList(), "null-match", null)); + final Allocation nullAllocation = + new Allocation("null-alloc", nullRules, null, null, nullSplits, false); + + return new Flag( + "is-null-non-boolean-flag", + true, + ValueType.STRING, + variants, + singletonList(nullAllocation)); + } + + private Flag createNullAttributeFlag() { + final Map variants = new HashMap<>(); + variants.put("fallback", new Variant("fallback", "fallback")); + + // Rule: null_attribute MATCHES "test" -> should not match due to null attribute + final List nullAttrConditions = + singletonList(new ConditionConfiguration(ConditionOperator.MATCHES, null, "test")); + final List nullAttrRules = singletonList(new Rule(nullAttrConditions)); + final List nullAttrSplits = singletonList(new Split(emptyList(), "fallback", null)); + final Allocation nullAttrAllocation = + new Allocation("null-attr-alloc", nullAttrRules, null, null, nullAttrSplits, false); + + return new Flag( + "null-attribute-flag", true, ValueType.STRING, variants, singletonList(nullAttrAllocation)); + } + + private Flag createNotMatchesPositiveFlag() { + final Map variants = new HashMap<>(); + variants.put("external", new Variant("external", "external-email")); + + // Rule: email NOT_MATCHES "@company.com" -> external (should match gmail.com) + final List externalConditions = + singletonList( + new ConditionConfiguration(ConditionOperator.NOT_MATCHES, "email", "@company\\.com")); + final List externalRules = singletonList(new Rule(externalConditions)); + final List externalSplits = singletonList(new Split(emptyList(), "external", null)); + final Allocation externalAllocation = + new Allocation("external-alloc", externalRules, null, null, externalSplits, false); + + return new Flag( + "not-matches-positive-flag", + true, + ValueType.STRING, + variants, + singletonList(externalAllocation)); + } + + private Flag createNotOneOfPositiveFlag() { + final Map variants = new HashMap<>(); + variants.put("other", new Variant("other", "other-region")); + + // Rule: region NOT_ONE_OF ["us-east-1", "us-west-2", "eu-west-1"] -> other + final List excludedRegions = asList("us-east-1", "us-west-2", "eu-west-1"); + final List otherConditions = + singletonList( + new ConditionConfiguration(ConditionOperator.NOT_ONE_OF, "region", excludedRegions)); + final List otherRules = singletonList(new Rule(otherConditions)); + final List otherSplits = singletonList(new Split(emptyList(), "other", null)); + final Allocation otherAllocation = + new Allocation("other-alloc", otherRules, null, null, otherSplits, false); + + return new Flag( + "not-one-of-positive-flag", + true, + ValueType.STRING, + variants, + singletonList(otherAllocation)); + } + + private Flag createFalseNumericComparisonsFlag() { + final Map variants = new HashMap<>(); + variants.put("high-score", new Variant("high-score", "high-score")); + + // Rule: score GTE 800 -> high-score (test will provide 750, should fail) + final List highScoreConditions = + singletonList(new ConditionConfiguration(ConditionOperator.GTE, "score", 800)); + final List highScoreRules = singletonList(new Rule(highScoreConditions)); + final List highScoreSplits = singletonList(new Split(emptyList(), "high-score", null)); + final Allocation highScoreAllocation = + new Allocation("high-score-alloc", highScoreRules, null, null, highScoreSplits, false); + + return new Flag( + "false-numeric-comparisons-flag", + true, + ValueType.STRING, + variants, + singletonList(highScoreAllocation)); + } + + private Flag createEmptySplitsFlag() { + final Map variants = new HashMap<>(); + variants.put("default", new Variant("default", "default")); + + // Allocation with null splits + final Allocation allocation = + new Allocation("empty-splits-alloc", null, null, null, null, false); + + return new Flag( + "empty-splits-flag", true, ValueType.STRING, variants, singletonList(allocation)); + } + + private Flag createEmptyConditionsFlag() { + final Map variants = new HashMap<>(); + variants.put("default", new Variant("default", "default")); + + // Rule with empty conditions list - this will be skipped, causing allocation to not match + final Rule emptyConditionsRule = new Rule(emptyList()); + final List splits = singletonList(new Split(emptyList(), "default", null)); + final Allocation allocation = + new Allocation( + "empty-conditions-alloc", + singletonList(emptyConditionsRule), + null, + null, + splits, + false); + + return new Flag( + "empty-conditions-flag", true, ValueType.STRING, variants, singletonList(allocation)); + } + + private Flag createShardMatchingFlag() { + final Map variants = new HashMap<>(); + variants.put("matched", new Variant("matched", "shard-matched")); + + // Create shard that will match the specific targeting key + final List ranges = + singletonList(new ShardRange(0, 100)); // Full range to ensure match + final List shards = singletonList(new Shard("test-salt", ranges, 100)); + final List splits = singletonList(new Split(shards, "matched", null)); + final Allocation allocation = + new Allocation("shard-matching-alloc", null, null, null, splits, false); + + return new Flag( + "shard-matching-flag", true, ValueType.STRING, variants, singletonList(allocation)); + } + + private Flag createFutureAllocationFlag() { + final Map variants = new HashMap<>(); + variants.put("future", new Variant("future", "future-value")); + + final List splits = singletonList(new Split(emptyList(), "future", null)); + + // Allocation that starts in the future (2050) + final Allocation allocation = + new Allocation("future-alloc", null, "2050-01-01T00:00:00Z", null, splits, false); + + return new Flag( + "future-allocation-flag", true, ValueType.STRING, variants, singletonList(allocation)); + } + + private Flag createIdAttributeFlag() { + final Map variants = new HashMap<>(); + variants.put("id-match", new Variant("id-match", "id-resolved")); + + // Rule that checks for "id" attribute (will use targeting key if not provided) + final List conditions = + singletonList( + new ConditionConfiguration(ConditionOperator.MATCHES, "id", "user-special-id")); + final List rules = singletonList(new Rule(conditions)); + final List splits = singletonList(new Split(emptyList(), "id-match", null)); + final Allocation allocation = new Allocation("id-attr-alloc", rules, null, null, splits, false); + + return new Flag( + "id-attribute-flag", true, ValueType.STRING, variants, singletonList(allocation)); + } + + private Flag createNonIterableConditionFlag() { + final Map variants = new HashMap<>(); + variants.put("no-match", new Variant("no-match", "no-match")); + + // Rule with ONE_OF condition but non-iterable value (String instead of List) + final List conditions = + singletonList(new ConditionConfiguration(ConditionOperator.ONE_OF, "attr", "single-value")); + final List rules = singletonList(new Rule(conditions)); + final List splits = singletonList(new Split(emptyList(), "no-match", null)); + final Allocation allocation = + new Allocation("non-iterable-alloc", rules, null, null, splits, false); + + return new Flag( + "non-iterable-condition-flag", true, ValueType.STRING, variants, singletonList(allocation)); + } + + private Flag createGtFalseFlag() { + return createComparisonFlag( + "gt-false-flag", + "gt-false-alloc", + "high", + "high-value", + ConditionOperator.GT, + "score", + 600); + } + + private Flag createLteFalseFlag() { + return createComparisonFlag( + "lte-false-flag", + "lte-false-alloc", + "low", + "low-value", + ConditionOperator.LTE, + "score", + 500); + } + + private Flag createLtFalseFlag() { + return createComparisonFlag( + "lt-false-flag", + "lt-false-alloc", + "very-low", + "very-low-value", + ConditionOperator.LT, + "score", + 600); + } + + private Flag createNotMatchesFalseFlag() { + final Map variants = new HashMap<>(); + variants.put("internal", new Variant("internal", "internal-email")); + + // Rule: email NOT_MATCHES "@company.com" -> internal (test provides company.com, should fail) + final List conditions = + singletonList( + new ConditionConfiguration(ConditionOperator.NOT_MATCHES, "email", "@company\\.com")); + final List rules = singletonList(new Rule(conditions)); + final List splits = singletonList(new Split(emptyList(), "internal", null)); + final Allocation allocation = + new Allocation("not-matches-false-alloc", rules, null, null, splits, false); + + return new Flag( + "not-matches-false-flag", true, ValueType.STRING, variants, singletonList(allocation)); + } + + private Flag createNotOneOfFalseFlag() { + final Map variants = new HashMap<>(); + variants.put("excluded", new Variant("excluded", "excluded-region")); + + // Rule: region NOT_ONE_OF ["us-east-1", "us-west-2"] -> excluded (test provides us-east-1, + // should fail) + final List excludedRegions = asList("us-east-1", "us-west-2"); + final List conditions = + singletonList( + new ConditionConfiguration(ConditionOperator.NOT_ONE_OF, "region", excludedRegions)); + final List rules = singletonList(new Rule(conditions)); + final List splits = singletonList(new Split(emptyList(), "excluded", null)); + final Allocation allocation = + new Allocation("not-one-of-false-alloc", rules, null, null, splits, false); + + return new Flag( + "not-one-of-false-flag", true, ValueType.STRING, variants, singletonList(allocation)); + } + + private Flag createNullContextValuesFlag() { + final Map variants = new HashMap<>(); + variants.put("null-variant", new Variant("null-variant", "null-handled")); + + // Rule that will handle null context values in flattening + final List conditions = + singletonList(new ConditionConfiguration(ConditionOperator.IS_NULL, "nullAttr", true)); + final List rules = singletonList(new Rule(conditions)); + final List splits = singletonList(new Split(emptyList(), "null-variant", null)); + final Allocation allocation = + new Allocation("null-context-alloc", rules, null, null, splits, false); + + return new Flag( + "null-context-values-flag", true, ValueType.STRING, variants, singletonList(allocation)); + } + + private static Map mapOf(final Object... props) { + final Map result = new HashMap<>(props.length << 1); + int index = 0; + while (index < props.length) { + final String key = String.valueOf(props[index++]); + final Object value = props[index++]; + result.put(key, value); + } + return result; + } +} diff --git a/products/openfeature/src/test/java/datadog/trace/api/openfeature/ProviderTest.java b/products/openfeature/src/test/java/datadog/trace/api/openfeature/ProviderTest.java new file mode 100644 index 00000000000..4ed1495bd00 --- /dev/null +++ b/products/openfeature/src/test/java/datadog/trace/api/openfeature/ProviderTest.java @@ -0,0 +1,179 @@ +package datadog.trace.api.openfeature; + +import static datadog.trace.api.openfeature.Provider.METADATA; +import static java.time.Duration.ofSeconds; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import datadog.trace.api.openfeature.Provider.Options; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.EventDetails; +import dev.openfeature.sdk.Features; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEvent; +import dev.openfeature.sdk.ProviderState; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.FatalError; +import dev.openfeature.sdk.exceptions.ProviderNotReadyError; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Consumer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class ProviderTest { + + @Captor private ArgumentCaptor eventDetailsCaptor; + + private ExecutorService executor; + + @BeforeEach + public void setup() { + executor = Executors.newSingleThreadExecutor(); + } + + @AfterEach + public void tearDown() { + executor.shutdownNow(); + OpenFeatureAPI.getInstance().shutdown(); + FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + } + + @Test + public void testSetProvider() { + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setProvider(new Provider()); + + final Client client = api.getClient(); + assertThat(client.getProviderState(), equalTo(ProviderState.NOT_READY)); + + FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + await().atMost(ofSeconds(1)).until(() -> client.getProviderState() == ProviderState.READY); + } + + @Test + public void testSetProviderAndWait() { + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + executor.submit(() -> api.setProviderAndWait(new Provider())); + + final Client client = api.getClient(); + assertThat(client.getProviderState(), equalTo(ProviderState.NOT_READY)); + + FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + await().atMost(ofSeconds(1)).until(() -> client.getProviderState() == ProviderState.READY); + } + + @Test + public void testSetProviderAndWaitTimeout() { + final Consumer readyEvent = mock(Consumer.class); + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + final Client client = api.getClient(); + client.on(ProviderEvent.PROVIDER_READY, readyEvent); + + // we time out after 10 millis without receiving the initial config + assertThrows( + ProviderNotReadyError.class, + () -> api.setProviderAndWait(new Provider(new Options().initTimeout(10, MILLISECONDS)))); + + // ready has not yet been called + verify(readyEvent, times(0)).accept(any()); + + // dispatch an initial configuration + FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + + // ready is called after receiving the configuration + await() + .atMost(ofSeconds(1)) + .untilAsserted( + () -> { + verify(readyEvent, times(1)).accept(eventDetailsCaptor.capture()); + final EventDetails details = eventDetailsCaptor.getValue(); + assertThat(details.getProviderName(), equalTo(METADATA)); + }); + } + + @Test + public void testFailureToLoadInternalApi() { + @SuppressWarnings("unchecked") + final Consumer consumer = mock(Consumer.class); + + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.onProviderError(consumer); + + assertThrows( + FatalError.class, + () -> + api.setProviderAndWait( + new Provider() { + @Override + protected Class loadEvaluatorClass() throws ClassNotFoundException { + throw new ClassNotFoundException( + "Class " + FeatureFlaggingGateway.class.getName() + " not found"); + } + })); + } + + public interface EvaluateMethod { + FlagEvaluationDetails evaluate(Features client, String flag, E defaultValue); + } + + private static Arguments[] providerMethods() { + return new Arguments[] { + Arguments.of("bool", false, (EvaluateMethod) Features::getBooleanDetails), + Arguments.of("string", "Hello!", (EvaluateMethod) Features::getStringDetails), + Arguments.of("int", 23, (EvaluateMethod) Features::getIntegerDetails), + Arguments.of("double", 3.14D, (EvaluateMethod) Features::getDoubleDetails), + Arguments.of("object", new Value(), (EvaluateMethod) Features::getObjectDetails) + }; + } + + @MethodSource("providerMethods") + @ParameterizedTest + public void testProviderEvaluation( + final String flag, final E defaultValue, final EvaluateMethod method) throws Exception { + FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + final Evaluator evaluator = mock(Evaluator.class); + when(evaluator.initialize(anyLong(), any(), any())).thenReturn(true); + when(evaluator.evaluate(any(), any(), any(), any())) + .thenAnswer( + invocation -> + ProviderEvaluation.builder() + .value(invocation.getArgument(2)) + .reason("MOCK") + .build()); + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setProviderAndWait(new Provider(new Options().initTimeout(10, SECONDS), evaluator)); + final Client client = api.getClient(); + final FlagEvaluationDetails result = method.evaluate(client, flag, defaultValue); + assertThat(result.getValue(), equalTo(defaultValue)); + assertThat(result.getReason(), equalTo("MOCK")); + verify(evaluator, times(1)).initialize(eq(10L), eq(SECONDS), any()); + verify(evaluator, times(1)) + .evaluate(any(), eq(flag), eq(defaultValue), any(EvaluationContext.class)); + } +} diff --git a/products/openfeature/src/test/java/datadog/trace/api/openfeature/util/TestCase.java b/products/openfeature/src/test/java/datadog/trace/api/openfeature/util/TestCase.java new file mode 100644 index 00000000000..b19f85bf84e --- /dev/null +++ b/products/openfeature/src/test/java/datadog/trace/api/openfeature/util/TestCase.java @@ -0,0 +1,114 @@ +package datadog.trace.api.openfeature.util; + +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.Structure; +import dev.openfeature.sdk.Value; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class TestCase { + + public Class type; + public String flag; + public E defaultValue; + public final MutableContext context = new MutableContext(); + public Result result; + + @SuppressWarnings("unchecked") + public TestCase(final E defaultValue) { + this.type = (Class) defaultValue.getClass(); + this.defaultValue = defaultValue; + } + + public TestCase flag(String flag) { + this.flag = flag; + return this; + } + + public TestCase targetingKey(final String targetingKey) { + context.setTargetingKey(targetingKey); + return this; + } + + public TestCase context(final String key, final String value) { + context.add(key, value); + return this; + } + + public TestCase context(final String key, final Integer value) { + context.add(key, value); + return this; + } + + public TestCase context(final String key, final Double value) { + context.add(key, value); + return this; + } + + public TestCase context(final String key, final Boolean value) { + context.add(key, value); + return this; + } + + public TestCase context(final String key, final Structure value) { + context.add(key, value); + return this; + } + + public TestCase context(final String key, final List value) { + context.add(key, value); + return this; + } + + public TestCase result(final Result result) { + this.result = result; + return this; + } + + @Override + public String toString() { + return "TestCase{" + + "flag='" + + flag + + '\'' + + ", defaultValue=" + + defaultValue + + ", targetingKey=" + + context.getTargetingKey() + + '}'; + } + + public static class Result { + public E value; + public String variant; + public String[] reason; + public ErrorCode errorCode; + public final Map flagMetadata = new HashMap<>(); + + public Result(final E value) { + this.value = value; + } + + public Result variant(final String variant) { + this.variant = variant; + return this; + } + + public Result errorCode(final ErrorCode errorCode) { + this.errorCode = errorCode; + return this; + } + + public Result reason(final String... reason) { + this.reason = reason; + return this; + } + + public Result flagMetadata(final String name, final Object value) { + flagMetadata.put(name, value); + return this; + } + } +} diff --git a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java index 7ddd4e0c693..ce76e59000a 100644 --- a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java +++ b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java @@ -46,4 +46,5 @@ public interface Capabilities { long CAPABILITY_ASM_TRACE_TAGGING_RULES = 1L << 43; long CAPABILITY_ASM_EXTENDED_DATA_COLLECTION = 1L << 44; long CAPABILITY_APM_TRACING_MULTICONFIG = 1L << 45; + long CAPABILITY_FFE_FLAG_CONFIGURATION_RULES = 1L << 46; } diff --git a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Product.java b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Product.java index 7b079cf56d0..c018205d958 100644 --- a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Product.java +++ b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Product.java @@ -11,5 +11,6 @@ public enum Product { ASM, ASM_DATA, ASM_FEATURES, + FFE_FLAGS, _UNKNOWN, } diff --git a/settings.gradle.kts b/settings.gradle.kts index 92aba9c108e..7e1bd1dd560 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -141,6 +141,12 @@ include( // AI Guard include(":dd-java-agent:agent-aiguard") +// Feature Flag +include( + ":dd-java-agent:agent-feature-flagging", + ":products:openfeature" +) + // misc include( ":dd-java-agent:testing",