Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,27 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener {
static final AtomicBoolean SPLIT_FEATURES_SUPPORTED =
new AtomicBoolean(splitFeaturesSupported(Split.class));

static final AtomicBoolean EXPOSURE_FEATURES_SUPPORTED =

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Comment from Claude working with Aaron Silverman:

[P2] Move the agent-capability probes into one class, and note on each the dd-java-agent version that added the capability. This stack takes them from two to five, spread over DDEvaluator and FlagEvalLoggingHook, and none of them names the version it depends on.

One class shows the version skew in one place, and makes each probe easy to delete when dd-openfeature raises its minimum agent version. The class must keep FlagEvalEvent out of its own static initializer. On an agent before 1.66.0 that would throw NoClassDefFoundError and break DDEvaluator, which is the bug that bc1cdd4502 fixed. A nested holder class that only FlagEvalLoggingHook loads avoids it, and a test pins it.

Suggested change — AgentSupport, on top of the enum from my previous comment (tested)

This diff is relative to the previous suggestion. The other four test files only change their references from DDEvaluator.* and FlagEvalLoggingHook.* to AgentSupport.*; they are left out here.

diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/AgentSupport.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/AgentSupport.java
new file mode 100644
--- /dev/null
+++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/AgentSupport.java
@@ -0,0 +1,153 @@
+package datadog.trace.api.openfeature;
+
+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 datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent;
+import datadog.trace.api.featureflag.ufc.v1.Split;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * What the installed dd-java-agent supports. dd-openfeature is released separately from the agent
+ * that ships its bootstrap classes, so each capability is probed once, by reflection, into a field
+ * tests can override. A probe can be deleted once dd-openfeature requires an agent that has it.
+ */
+final class AgentSupport {
+
+  private static final Logger log = LoggerFactory.getLogger(AgentSupport.class);
+
+  /** Split.serialId: dd-java-agent 1.65.0. */
+  static final AtomicBoolean SPLIT_SERIAL_ID_SUPPORTED =
+      new AtomicBoolean(splitSerialIdSupported(Split.class));
+
+  /** The newest ExposureEvent constructor; each {@link ExposureEventApi} names its release. */
+  static final AtomicReference<ExposureEventApi> EXPOSURE_EVENT_API =
+      new AtomicReference<>(exposureEventApi(SPLIT_SERIAL_ID_SUPPORTED.get(), ExposureEvent.class));
+
+  /** Split.features: the dd-java-agent release after this change. */
+  static final AtomicBoolean SPLIT_FEATURES_SUPPORTED =
+      new AtomicBoolean(splitFeaturesSupported(Split.class));
+
+  /** The ExposureEvent constructors an agent offers, oldest first. */
+  enum ExposureEventApi {
+    /** Agents before 1.67.0. */
+    WITHOUT_SERIAL_ID,
+    /** The 6-arg constructor: dd-java-agent 1.67.0. */
+    WITH_SERIAL_ID,
+    /** The 7-arg constructor: the dd-java-agent release after this change. */
+    WITH_FEATURES
+  }
+
+  /** The newest exposure event the agent offers; without Split.serialId, the oldest. */
+  static ExposureEventApi exposureEventApi(
+      final boolean splitSerialIdSupported, final Class<?> eventClass) {
+    if (!splitSerialIdSupported || !exposureSerialIdSupported(eventClass)) {
+      return ExposureEventApi.WITHOUT_SERIAL_ID;
+    }
+    return exposureFeaturesSupported(eventClass)
+        ? ExposureEventApi.WITH_FEATURES
+        : ExposureEventApi.WITH_SERIAL_ID;
+  }
+
+  /** An older agent's exposure event has no features; its exposures are then sent without them. */
+  static boolean exposureFeaturesSupported(final Class<?> eventClass) {
+    try {
+      eventClass.getConstructor(
+          long.class,
+          Allocation.class,
+          Flag.class,
+          Variant.class,
+          Subject.class,
+          Integer.class,
+          Map.class);
+      return true;
+    } catch (final NoSuchMethodException | LinkageError | RuntimeException e) {
+      log.debug("The installed Datadog Java agent does not send features on exposures", e);
+      return false;
+    }
+  }
+
+  /** An older agent ships a Split without features; its evaluations then carry no features. */
+  static boolean splitFeaturesSupported(final Class<?> splitClass) {
+    try {
+      return splitClass.getField("features").getType() == List.class;
+    } catch (final NoSuchFieldException | LinkageError | RuntimeException e) {
+      log.debug("The installed Datadog Java agent does not carry split features", e);
+      return false;
+    }
+  }
+
+  static boolean splitSerialIdSupported(final Class<?> splitClass) {
+    try {
+      return splitClass.getField("serialId").getType() == Integer.class;
+    } catch (final NoSuchFieldException | LinkageError | RuntimeException e) {
+      log.warn(
+          "Feature flag serial ID reporting is unavailable with the installed Datadog Java "
+              + "agent, which does not carry a serial id on the flag configuration. Upgrade "
+              + "dd-java-agent to enable holdout attribution.");
+      log.debug("Unable to access the flag configuration serial ID", e);
+      return false;
+    }
+  }
+
+  static boolean exposureSerialIdSupported(final Class<?> eventClass) {
+    try {
+      eventClass.getConstructor(
+          long.class, Allocation.class, Flag.class, Variant.class, Subject.class, Integer.class);
+      return true;
+    } catch (final NoSuchMethodException | LinkageError | RuntimeException e) {
+      log.warn(
+          "Feature flag exposure serial ID reporting is unavailable with the installed "
+              + "Datadog Java agent. Exposures are still reported, without the serial id, and "
+              + "span enrichment is unaffected. Upgrade dd-java-agent to enable holdout "
+              + "attribution on exposures.");
+      log.debug("Unable to access the exposure serial ID constructor", e);
+      return false;
+    }
+  }
+
+  /**
+   * FlagEvalEvent: dd-java-agent 1.66.0. Probed here rather than in AgentSupport so that loading
+   * AgentSupport, as DDEvaluator does, never resolves FlagEvalEvent. Only FlagEvalLoggingHook,
+   * which contains the LinkageError an older agent raises, reads it.
+   */
+  static final class FlagEvalEvents {
+
+    /** The 9-arg FlagEvalEvent constructor: the dd-java-agent release after this change. */
+    static final AtomicBoolean FEATURES_SUPPORTED =
+        new AtomicBoolean(featuresSupported(FlagEvalEvent.class));
+
+    /**
+     * An older agent's flag-evaluation event has no features; its rows are then sent without them.
+     */
+    static boolean featuresSupported(final Class<?> eventClass) {
+      try {
+        eventClass.getConstructor(
+            String.class,
+            String.class,
+            String.class,
+            String.class,
+            String.class,
+            long.class,
+            boolean.class,
+            Map.class,
+            Map.class);
+        return true;
+      } catch (final NoSuchMethodException | LinkageError | RuntimeException e) {
+        log.debug("The installed Datadog Java agent does not send features on flag evaluations", e);
+        return false;
+      }
+    }
+
+    private FlagEvalEvents() {}
+  }
+
+  private AgentSupport() {}
+}
diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java
--- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java
+++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java
@@ -18,6 +18,7 @@ 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.AgentSupport.ExposureEventApi;
 import dev.openfeature.sdk.ErrorCode;
 import dev.openfeature.sdk.EvaluationContext;
 import dev.openfeature.sdk.ImmutableMetadata;
@@ -45,7 +46,6 @@ import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
@@ -58,98 +58,6 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener {
   private static final Set<Class<?>> SUPPORTED_RESOLUTION_TYPES =
       new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Value.class));
 
-  static final AtomicBoolean SPLIT_SERIAL_ID_SUPPORTED =
-      new AtomicBoolean(splitSerialIdSupported(Split.class));
-
-  /** The ExposureEvent constructors an agent offers, oldest first. */
-  enum ExposureEventApi {
-    /** Agents before 1.67.0. */
-    WITHOUT_SERIAL_ID,
-    /** Agent 1.67.0+. */
-    WITH_SERIAL_ID,
-    /** The agent release after this change. */
-    WITH_FEATURES
-  }
-
-  static final AtomicReference<ExposureEventApi> EXPOSURE_EVENT_API =
-      new AtomicReference<>(exposureEventApi(SPLIT_SERIAL_ID_SUPPORTED.get(), ExposureEvent.class));
-
-  static final AtomicBoolean SPLIT_FEATURES_SUPPORTED =
-      new AtomicBoolean(splitFeaturesSupported(Split.class));
-
-  /** The newest exposure event the agent offers; without Split.serialId, the oldest. */
-  static ExposureEventApi exposureEventApi(
-      final boolean splitSerialIdSupported, final Class<?> eventClass) {
-    if (!splitSerialIdSupported || !exposureSerialIdSupported(eventClass)) {
-      return ExposureEventApi.WITHOUT_SERIAL_ID;
-    }
-    return exposureFeaturesSupported(eventClass)
-        ? ExposureEventApi.WITH_FEATURES
-        : ExposureEventApi.WITH_SERIAL_ID;
-  }
-
-  /** An older agent's exposure event has no features; its exposures are then sent without them. */
-  static boolean exposureFeaturesSupported(final Class<?> eventClass) {
-    try {
-      eventClass.getConstructor(
-          long.class,
-          datadog.trace.api.featureflag.exposure.Allocation.class,
-          datadog.trace.api.featureflag.exposure.Flag.class,
-          datadog.trace.api.featureflag.exposure.Variant.class,
-          Subject.class,
-          Integer.class,
-          Map.class);
-      return true;
-    } catch (final NoSuchMethodException | LinkageError | RuntimeException e) {
-      log.debug("The installed Datadog Java agent does not send features on exposures", e);
-      return false;
-    }
-  }
-
-  /** An older agent ships a Split without features; its evaluations then carry no features. */
-  static boolean splitFeaturesSupported(final Class<?> splitClass) {
-    try {
-      return splitClass.getField("features").getType() == List.class;
-    } catch (final NoSuchFieldException | LinkageError | RuntimeException e) {
-      log.debug("The installed Datadog Java agent does not carry split features", e);
-      return false;
-    }
-  }
-
-  static boolean splitSerialIdSupported(final Class<?> splitClass) {
-    try {
-      return splitClass.getField("serialId").getType() == Integer.class;
-    } catch (final NoSuchFieldException | LinkageError | RuntimeException e) {
-      log.warn(
-          "Feature flag serial ID reporting is unavailable with the installed Datadog Java "
-              + "agent, which does not carry a serial id on the flag configuration. Upgrade "
-              + "dd-java-agent to enable holdout attribution.");
-      log.debug("Unable to access the flag configuration serial ID", e);
-      return false;
-    }
-  }
-
-  static boolean exposureSerialIdSupported(final Class<?> eventClass) {
-    try {
-      eventClass.getConstructor(
-          long.class,
-          datadog.trace.api.featureflag.exposure.Allocation.class,
-          datadog.trace.api.featureflag.exposure.Flag.class,
-          datadog.trace.api.featureflag.exposure.Variant.class,
-          Subject.class,
-          Integer.class);
-      return true;
-    } catch (final NoSuchMethodException | LinkageError | RuntimeException e) {
-      log.warn(
-          "Feature flag exposure serial ID reporting is unavailable with the installed "
-              + "Datadog Java agent. Exposures are still reported, without the serial id, and "
-              + "span enrichment is unaffected. Upgrade dd-java-agent to enable holdout "
-              + "attribution on exposures.");
-      log.debug("Unable to access the exposure serial ID constructor", e);
-      return false;
-    }
-  }
-
   /**
    * Maximum evaluation-context nesting depth captured on the hot path. Recursion runs on the
    * caller's evaluation thread over a caller-owned Value tree, so an arbitrarily deep
@@ -673,12 +581,12 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener {
     // __dd_split_serial_id is omitted when the split carries no serial id.
     final boolean doLog = allocation.doLog != null && allocation.doLog;
     if (SPAN_ENRICHMENT_ENABLED || doLog) {
-      if (SPLIT_SERIAL_ID_SUPPORTED.get() && split.serialId != null) {
+      if (AgentSupport.SPLIT_SERIAL_ID_SUPPORTED.get() && split.serialId != null) {
         metadataBuilder.addInteger(METADATA_SPLIT_SERIAL_ID, split.serialId);
       }
       metadataBuilder.addBoolean(METADATA_DO_LOG, doLog);
     }
-    if (SPLIT_FEATURES_SUPPORTED.get()) {
+    if (AgentSupport.SPLIT_FEATURES_SUPPORTED.get()) {
       addSplitFeatures(metadataBuilder, split);
     }
     if (doLog) {
@@ -788,8 +696,8 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener {
 
   /** The serial id an exposure carries: none when the agent's exposure event predates it. */
   static Integer exposureSerialId(final Integer splitSerialId) {
-    return EXPOSURE_EVENT_API.get() == ExposureEventApi.WITHOUT_SERIAL_ID
-            || !SPLIT_SERIAL_ID_SUPPORTED.get()
+    return AgentSupport.EXPOSURE_EVENT_API.get() == ExposureEventApi.WITHOUT_SERIAL_ID
+            || !AgentSupport.SPLIT_SERIAL_ID_SUPPORTED.get()
         ? null
         : splitSerialId;
   }
@@ -878,7 +786,7 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener {
         new datadog.trace.api.featureflag.exposure.Variant(variantKey);
     final Subject subject = new Subject(context.getTargetingKey(), flattenContext(context));
 
-    final ExposureEventApi api = EXPOSURE_EVENT_API.get();
+    final ExposureEventApi api = AgentSupport.EXPOSURE_EVENT_API.get();
     if (api == ExposureEventApi.WITHOUT_SERIAL_ID) {
       return new ExposureEvent(timestamp, allocation, exposureFlag, variant, subject);
     }
diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java
--- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java
+++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java
@@ -9,10 +9,7 @@ import dev.openfeature.sdk.HookContext;
 import dev.openfeature.sdk.ImmutableMetadata;
 import java.util.Collections;
 import java.util.Map;
-import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.Supplier;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /**
  * OpenFeature Hook that captures flag evaluation events for EVP flagevaluation emission.
@@ -36,33 +33,6 @@ import org.slf4j.LoggerFactory;
  */
 class FlagEvalLoggingHook<T> implements Hook<T> {
 
-  private static final Logger log = LoggerFactory.getLogger(FlagEvalLoggingHook.class);
-
-  static final AtomicBoolean FEATURES_SUPPORTED =
-      new AtomicBoolean(featuresSupported(FlagEvalEvent.class));
-
-  /**
-   * An older agent's flag-evaluation event has no features; its rows are then sent without them.
-   */
-  static boolean featuresSupported(final Class<?> eventClass) {
-    try {
-      eventClass.getConstructor(
-          String.class,
-          String.class,
-          String.class,
-          String.class,
-          String.class,
-          long.class,
-          boolean.class,
-          Map.class,
-          Map.class);
-      return true;
-    } catch (final NoSuchMethodException | LinkageError | RuntimeException e) {
-      log.debug("The installed Datadog Java agent does not send features on flag evaluations", e);
-      return false;
-    }
-  }
-
   /**
    * Singleton instance: always registered when the provider is created; harmless when writer=null
    * (killswitch off or not yet started).
@@ -186,7 +156,7 @@ class FlagEvalLoggingHook<T> implements Hook<T> {
       final Map<String, Object> features =
           DDEvaluator.featuresWithPrefix(metadata, DDEvaluator.METADATA_EVALUATION_FEATURE_PREFIX);
       w.enqueue(
-          features.isEmpty() || !FEATURES_SUPPORTED.get()
+          features.isEmpty() || !AgentSupport.FlagEvalEvents.FEATURES_SUPPORTED.get()
               ? new FlagEvalEvent(
                   flagKey,
                   variant,
diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/AgentSupportTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/AgentSupportTest.java
new file mode 100644
--- /dev/null
+++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/AgentSupportTest.java
@@ -0,0 +1,73 @@
+package datadog.trace.api.openfeature;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Test;
+
+class AgentSupportTest {
+
+  /**
+   * Agents before 1.66.0 have no FlagEvalEvent. Resolving it while DDEvaluator or AgentSupport
+   * initializes would break every flag evaluation, so only the FlagEvalEvents holder may.
+   */
+  @Test
+  void initializesWithAnAgentWithoutFlagEvalEvent() throws Exception {
+    final ClassLoader agent = new AgentWithoutFlagEvalEvent();
+
+    Class.forName(DDEvaluator.class.getName(), true, agent);
+    final Field exposureEventApi =
+        Class.forName(AgentSupport.class.getName(), true, agent)
+            .getDeclaredField("EXPOSURE_EVENT_API");
+    exposureEventApi.setAccessible(true);
+
+    assertEquals(
+        AgentSupport.ExposureEventApi.WITH_FEATURES.name(),
+        ((Enum<?>) ((AtomicReference<?>) exposureEventApi.get(null)).get()).name());
+    assertThrows(
+        NoClassDefFoundError.class,
+        () -> Class.forName(AgentSupport.FlagEvalEvents.class.getName(), true, agent),
+        "the agent must really lack FlagEvalEvent");
+  }
+
+  /** Defines this package's classes itself, so they link against an agent without FlagEvalEvent. */
+  private static final class AgentWithoutFlagEvalEvent extends ClassLoader {
+    private static final String PACKAGE_PREFIX = AgentSupport.class.getPackage().getName() + ".";
+
+    AgentWithoutFlagEvalEvent() {
+      super(AgentSupportTest.class.getClassLoader());
+    }
+
+    @Override
+    protected Class<?> loadClass(final String name, final boolean resolve)
+        throws ClassNotFoundException {
+      if (name.equals(FlagEvalEvent.class.getName())) {
+        throw new ClassNotFoundException(name);
+      }
+      if (!name.startsWith(PACKAGE_PREFIX)) {
+        return super.loadClass(name, resolve);
+      }
+      synchronized (getClassLoadingLock(name)) {
+        final Class<?> loaded = findLoadedClass(name);
+        return loaded != null ? loaded : define(name);
+      }
+    }
+
+    private Class<?> define(final String name) throws ClassNotFoundException {
+      try (InputStream in = getParent().getResourceAsStream(name.replace('.', '/') + ".class")) {
+        if (in == null) {
+          throw new ClassNotFoundException(name);
+        }
+        final byte[] bytes = in.readAllBytes();
+        return defineClass(name, bytes, 0, bytes.length);
+      } catch (final IOException e) {
+        throw new ClassNotFoundException(name, e);
+      }
+    }
+  }
+}
Evidence — tests, two mutants, and the compiled classes
  • On a copy of this PR's head with both suggestions, :products:feature-flagging:feature-flagging-api:test passes 517 tests, forkedTest passes 4, and spotlessCheck passes.
  • AgentSupportTest loads the provider classes through a class loader that hides FlagEvalEvent, the class that agents before 1.66.0 lack. It covers that missing class only, not every other difference in an older agent. DDEvaluator and AgentSupport still initialize, and EXPOSURE_EVENT_API is WITH_FEATURES. Initializing AgentSupport$FlagEvalEvents in that loader throws NoClassDefFoundError, as a positive control.
  • Mutant 1: a FlagEvalEvent probe added to AgentSupport's static initializer makes the test fail with NoClassDefFoundError at AgentSupport.<clinit>.
  • Mutant 2: the probe moved back into DDEvaluator's static initializer, the original bug, makes it fail at DDEvaluator.<clinit>.
  • javap on the compiled classes: AgentSupport.class and DDEvaluator.class have no reference to FlagEvalEvent. AgentSupport$FlagEvalEvents.class has 3.

Two behavior changes:

  • The probes run later, on the first read of an AgentSupport field. For the Split and ExposureEvent probes, that is the first evaluation that resolves a variant, or the first exposure built; before, they ran when DDEvaluator loaded. For the FlagEvalEvent probe, it is the first time FlagEvalLoggingHook builds a row that has EVALUATION features; before, it ran when the hook loaded. So on an older agent, the serial-id warning appears at the first resolved evaluation, not at startup. To keep the old timing, read an AgentSupport field in DDEvaluator's static initializer, and an AgentSupport.FlagEvalEvents field in FlagEvalLoggingHook's.
  • The probe log lines come from the AgentSupport logger instead of the DDEvaluator and FlagEvalLoggingHook loggers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tracked as EX-3854.

This was replied to by Claude.

new AtomicBoolean(exposureFeaturesSupported(ExposureEvent.class));

/** An older agent's exposure event has no features; its exposures are then sent without them. */
static boolean exposureFeaturesSupported(final Class<?> eventClass) {
try {
eventClass.getConstructor(
long.class,
datadog.trace.api.featureflag.exposure.Allocation.class,
datadog.trace.api.featureflag.exposure.Flag.class,
datadog.trace.api.featureflag.exposure.Variant.class,
Subject.class,
Integer.class,
Map.class);
return true;
} catch (final NoSuchMethodException | LinkageError | RuntimeException e) {
log.debug("The installed Datadog Java agent does not send features on exposures", e);
return false;
}
}

/** An older agent ships a Split without features; its evaluations then carry no features. */
static boolean splitFeaturesSupported(final Class<?> splitClass) {
try {
Expand Down Expand Up @@ -164,12 +185,19 @@ static boolean exposureSerialIdSupported(final Class<?> eventClass) {
// Emitted only when the allocation logs exposures: true when this subject's exposure was already
// sent, so the exposure hook does not send it again.
static final String METADATA_EXPOSURE_CACHE_HIT = "__dd_exposure_cache_hit";
// Each feature of the selected split is copied to the metadata under this prefix.
// Each feature of the selected split is copied to the metadata once for each destination it
// lists, under that destination's prefix. Only the HOOK prefix is a public contract; the
// other two carry features to Datadog's own exposure and flag-evaluation hooks.
static final String METADATA_FEATURE_PREFIX = "__dd_feature.";
static final String METADATA_EXPOSURE_FEATURE_PREFIX = "__dd_exposure_feature.";
static final String METADATA_EVALUATION_FEATURE_PREFIX = "__dd_evaluation_feature.";
// Present only when the split routes a feature to EVALUATION, so FlagEvalLoggingHook can skip
// scanning the metadata on every other evaluation.
static final String METADATA_HAS_EVALUATION_FEATURES = "__dd_has_evaluation_features";

static final String FEATURE_DESTINATION_HOOK = "HOOK";
private static final Set<String> KNOWN_FEATURE_DESTINATIONS =
new HashSet<>(asList(FEATURE_DESTINATION_HOOK, "EXPOSURE", "EVALUATION"));
static final String FEATURE_DESTINATION_EXPOSURE = "EXPOSURE";
static final String FEATURE_DESTINATION_EVALUATION = "EVALUATION";
// A newer UFC can name destinations this SDK does not know; warn once per name, not per
// evaluation.
static final int MAX_WARNED_FEATURE_DESTINATIONS = 64;
Expand Down Expand Up @@ -669,40 +697,62 @@ private static void addSplitFeatures(
if (split.features == null) {
return;
}
Set<String> seenKeys = null;
boolean evaluationFeatures = false;
for (final Object item : split.features) {
if (!(item instanceof Feature)) {
continue;
}
final Feature feature = (Feature) item;
if (feature.key == null || !deliversToHook(feature)) {
if (feature.key == null || feature.destinations == null) {
continue;
}
final String key = METADATA_FEATURE_PREFIX + feature.key;
if (feature.value instanceof String) {
metadataBuilder.addString(key, (String) feature.value);
} else if (feature.value instanceof Boolean) {
metadataBuilder.addBoolean(key, (Boolean) feature.value);
} else if (feature.value instanceof Number) {
metadataBuilder.addDouble(key, ((Number) feature.value).doubleValue());
if (seenKeys == null) {
seenKeys = new HashSet<>();
}
if (!seenKeys.add(feature.key)) {
continue;
}
for (final String destination : feature.destinations) {
final String prefix = metadataPrefix(destination);
if (prefix != null) {
addFeature(metadataBuilder, prefix + feature.key, feature.value);
evaluationFeatures |= prefix.equals(METADATA_EVALUATION_FEATURE_PREFIX);
} else if (WARNED_FEATURE_DESTINATIONS.size() < MAX_WARNED_FEATURE_DESTINATIONS
&& WARNED_FEATURE_DESTINATIONS.add(destination)) {
log.warn("Ignoring unknown feature destination {}", destination);
}
}
}
if (evaluationFeatures) {
metadataBuilder.addBoolean(METADATA_HAS_EVALUATION_FEATURES, true);
}
}

private static boolean deliversToHook(final Feature feature) {
if (feature.destinations == null) {
return false;
private static String metadataPrefix(final String destination) {
if (FEATURE_DESTINATION_HOOK.equals(destination)) {
return METADATA_FEATURE_PREFIX;
}
boolean hook = false;
for (final String destination : feature.destinations) {
if (FEATURE_DESTINATION_HOOK.equals(destination)) {
hook = true;
} else if (!KNOWN_FEATURE_DESTINATIONS.contains(destination)
&& WARNED_FEATURE_DESTINATIONS.size() < MAX_WARNED_FEATURE_DESTINATIONS
&& WARNED_FEATURE_DESTINATIONS.add(destination)) {
log.warn("Ignoring unknown feature destination {}", destination);
}
if (FEATURE_DESTINATION_EXPOSURE.equals(destination)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Comment from Claude working with Aaron Silverman:

Reminder: release this only after ddoghq/dd-source#124673 is deployed. Until then, a flag configuration that routes a feature to EXPOSURE makes intake drop whole exposure batches, and one that routes a feature to EVALUATION does the same to flag-evaluation batches.

The intake schemas reject unknown fields, and #124673 is still open. Each worker decodes an application/json batch against its schema and drops the whole event when that fails. This SDK posts application/json. So one exposure with features drops every exposure in that request, from every flag. The provider cache has already recorded those subjects, so the provider does not send them again until their assignment changes or the cache evicts them. Nothing in this repo or in the Remote Config schema enforces that order: the schema on dd-source main already accepts any destination string.

Evidence — dd-source main 1b971bd26f (2026-10-08) and the open PR
  • ddoghq/dd-source#124673, "Accept features on exposures and flag evaluations", is open, with review required. It adds features to exposure.json, batched_exposures.json, flagevaluation.json and batchedflagevaluations.json.
  • exposures-worker/schemas/batched_exposures.json: each exposures item allows only allocation, flag, serial_id, subject, timestamp and variant, with additionalProperties: false. In flageval-worker/schemas/batchedflagevaluations.json, each flagEvaluations item has additionalProperties: false and no features.
  • exposures-worker/src/worker.ts, getExposures: for application/json it calls decodeJson(event.payload, BatchedExposuresSchema). process catches the error and returns drop(dropReasons.invalidEvent) for the whole event. flageval-worker/src/worker.ts does the same.
  • The stdlib decodeJson throws when the schema check fails. With the ajv validator that is a SchemaValidationError.
  • exposures-worker/src/worker.test.ts already notes the mechanism: "Without serial_id in the schema, additionalProperties:false would have rejected the whole event."
  • In this repo, FeatureFlagEvpPublisher posts with MediaType.parse("application/json").
  • remote-config/shared/libs/config-validator/schemas/FFE_FLAGS/root.json (commit 7ac915de08, on main) says of destinations: "Deliberately not an enum: SDKs ignore destinations they do not know."
Suggested PR body addition — a deployment checkbox
## Deployment

- [ ] ddoghq/dd-source#124673 has merged and is deployed in every datacenter.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Nothing emits EXPOSURE or EVALUATION yet, because holdout features are HOOK only. The plan gates any such configuration on ddoghq/dd-source#124673 being deployed in every datacenter.

This was replied to by Claude.

return METADATA_EXPOSURE_FEATURE_PREFIX;
}
if (FEATURE_DESTINATION_EVALUATION.equals(destination)) {
return METADATA_EVALUATION_FEATURE_PREFIX;
}
return null;
}

private static void addFeature(
final ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder,
final String key,
final Object value) {
if (value instanceof String) {
metadataBuilder.addString(key, (String) value);
} else if (value instanceof Boolean) {
metadataBuilder.addBoolean(key, (Boolean) value);
} else if (value instanceof Number) {
metadataBuilder.addDouble(key, ((Number) value).doubleValue());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Comment from Claude working with Aaron Silverman:

[P2] Settle the type of bandit.policy_id before the pilot: a numeric feature value reaches Datadog as a double. 42 is sent as 42.0, and 10000000 as 1.0E7.

The bandit RFC carries the policy id as a split feature, and the RFC does not say whether it is a string or a number. The agent's parser turns every JSON number in a feature into a Double, and this line stores it with addDouble. Moshi then writes Double.toString, so exposure and flag-evaluation payloads show 42.0 and 1.0E7. Some integers above 2^53 also lose precision. If the analysis matches the policy id as text, 42.0 does not match 42. A string policy id passes through unchanged.

Evidence — measured at this PR's head
  • A UFC split with "value": 42 parses to Double 42.0, and "value": 10000000 to Double 1.0E7. "value": "p1" stays a String.
  • FeatureFlagEvpPublisher serializes exposure features as "features":{"bandit.policy_id":42.0} and "features":{"bandit.policy_id":1.0E7}.
Suggested fix

Have the UFC compiler emit bandit.policy_id as a string. That needs no change in this stack. Keeping integral numbers as integers in this SDK instead would take more than this line. FlagMapAdapter reads each flag through readJsonValue(), which already returns JSON numbers as Double before the feature adapter runs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A string policy id avoids this. I will raise it on the bandit RFC.

This was replied to by Claude.

}
return hook;
}

/** The serial id an exposure carries: none when the agent's exposure event predates it. */
Expand Down Expand Up @@ -784,7 +834,8 @@ static ExposureEvent exposureEvent(
final String allocationKey,
final String variantKey,
final EvaluationContext context,
final Integer serialId) {
final Integer serialId,
final Map<String, Object> features) {
final long timestamp = System.currentTimeMillis();
// Exposure types share names with the imported UFC Allocation, Flag, and Variant types.
final datadog.trace.api.featureflag.exposure.Allocation allocation =
Expand All @@ -795,9 +846,14 @@ static ExposureEvent exposureEvent(
new datadog.trace.api.featureflag.exposure.Variant(variantKey);
final Subject subject = new Subject(context.getTargetingKey(), flattenContext(context));

return EXPOSURE_SERIAL_ID_SUPPORTED.get()
? new ExposureEvent(timestamp, allocation, exposureFlag, variant, subject, serialId)
: new ExposureEvent(timestamp, allocation, exposureFlag, variant, subject);
if (!EXPOSURE_SERIAL_ID_SUPPORTED.get()) {
return new ExposureEvent(timestamp, allocation, exposureFlag, variant, subject);
}
if (features.isEmpty() || !EXPOSURE_FEATURES_SUPPORTED.get()) {
return new ExposureEvent(timestamp, allocation, exposureFlag, variant, subject, serialId);
}
return new ExposureEvent(
timestamp, allocation, exposureFlag, variant, subject, serialId, features);
}

static AbstractMap<String, Object> flattenContext(final EvaluationContext context) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import dev.openfeature.sdk.HookContext;
import dev.openfeature.sdk.ImmutableMetadata;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
Expand Down Expand Up @@ -137,24 +136,8 @@ private Map<String, Object> metadata() {
* none.
*/
public Map<String, Object> getFeatures() {
final ImmutableMetadata metadata = details.getFlagMetadata();
if (metadata == null) {
return Collections.emptyMap();
}
Map<String, Object> features = null;
for (final Map.Entry<String, Object> entry : metadata.asUnmodifiableMap().entrySet()) {
if (entry.getKey().startsWith(DDEvaluator.METADATA_FEATURE_PREFIX)) {
if (features == null) {
features = new HashMap<>();
}
features.put(
entry.getKey().substring(DDEvaluator.METADATA_FEATURE_PREFIX.length()),
entry.getValue());
}
}
return features == null
? Collections.<String, Object>emptyMap()
: Collections.unmodifiableMap(features);
return SplitFeatureMetadata.featuresWithPrefix(
details.getFlagMetadata(), DDEvaluator.METADATA_FEATURE_PREFIX);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,14 @@ public void finallyAfter(
cache.record(flag, context.getTargetingKey(), allocationKey, variantKey, serialId);
if (sendToDatadog) {
FeatureFlaggingGateway.dispatch(
DDEvaluator.exposureEvent(flag, allocationKey, variantKey, context, serialId));
DDEvaluator.exposureEvent(
flag,
allocationKey,
variantKey,
context,
serialId,
SplitFeatureMetadata.featuresWithPrefix(
metadata, DDEvaluator.METADATA_EXPOSURE_FEATURE_PREFIX)));
}
} catch (LinkageError e) {
// Never let exposure reporting break flag evaluation.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
import dev.openfeature.sdk.ImmutableMetadata;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* OpenFeature Hook that captures flag evaluation events for EVP flagevaluation emission.
Expand All @@ -33,6 +36,33 @@
*/
class FlagEvalLoggingHook<T> implements Hook<T> {

private static final Logger log = LoggerFactory.getLogger(FlagEvalLoggingHook.class);

static final AtomicBoolean FEATURES_SUPPORTED =
new AtomicBoolean(featuresSupported(FlagEvalEvent.class));

/**
* An older agent's flag-evaluation event has no features; its rows are then sent without them.
*/
static boolean featuresSupported(final Class<?> eventClass) {
try {
eventClass.getConstructor(
String.class,
String.class,
String.class,
String.class,
String.class,
long.class,
boolean.class,
Map.class,
Map.class);
return true;
} catch (final NoSuchMethodException | LinkageError | RuntimeException e) {
log.debug("The installed Datadog Java agent does not send features on flag evaluations", e);
return false;
}
}

/**
* Singleton instance: always registered when the provider is created; harmless when writer=null
* (killswitch off or not yet started).
Expand Down Expand Up @@ -98,11 +128,17 @@ public void finallyAfter(
final ImmutableMetadata metadata = details.getFlagMetadata();

// allocationKey: "allocationKey" (camelCase) - consistent with FlagEvalMetricsHook.java
final String allocationKey = metadata != null ? metadata.getString("allocationKey") : null;
// The ImmutableMetadata getters build a debug message for every absent key.
final Map<String, Object> values =
metadata == null ? Collections.<String, Object>emptyMap() : metadata.asUnmodifiableMap();
final Object allocationValue = values.get("allocationKey");
final String allocationKey =
allocationValue instanceof String ? (String) allocationValue : null;

// eval-time: from flag metadata "__dd_eval_timestamp_ms" (Long), fallback to hook-fire time.
// ImmutableMetadata.getLong available since sdk 1.4+.
final Long evalTimeObj = metadata != null ? metadata.getLong("__dd_eval_timestamp_ms") : null;
final Object evalTimeValue = values.get("__dd_eval_timestamp_ms");
final Long evalTimeObj = evalTimeValue instanceof Long ? (Long) evalTimeValue : null;
final long evalTimeMs = evalTimeObj != null ? evalTimeObj : System.currentTimeMillis();

// variant: the OpenFeature variant key (same source as the OTel FlagEvalMetricsHook), NOT the
Expand All @@ -115,10 +151,9 @@ public void finallyAfter(

// Consent is read from metadata stamped by DDEvaluator (pinned to its ServerConfiguration).
// Missing key = non-DD provider → false, the privacy-preserving default.
final Object consentValue = values.get(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA);
final Boolean consentFromMetadata =
metadata != null
? metadata.getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA)
: null;
consentValue instanceof Boolean ? (Boolean) consentValue : null;
final boolean observeFullEvaluationData = consentFromMetadata != null && consentFromMetadata;

// Error message: prefer the human-readable message under consent-on; under consent-off the
Expand Down Expand Up @@ -151,16 +186,34 @@ public void finallyAfter(
attrs = Collections.emptyMap();
}

// Features come from the flag configuration, not the evaluation context, so they are sent
// whatever the consent.
final Map<String, Object> features =
Comment thread
danyal002 marked this conversation as resolved.
Comment thread
danyal002 marked this conversation as resolved.
Boolean.TRUE.equals(values.get(DDEvaluator.METADATA_HAS_EVALUATION_FEATURES))
? SplitFeatureMetadata.featuresWithPrefix(
metadata, DDEvaluator.METADATA_EVALUATION_FEATURE_PREFIX)
: Collections.<String, Object>emptyMap();
w.enqueue(
new FlagEvalEvent(
flagKey,
variant,
allocationKey,
targetingKey,
errorMessage,
evalTimeMs,
observeFullEvaluationData,
attrs));
features.isEmpty() || !FEATURES_SUPPORTED.get()
? new FlagEvalEvent(
flagKey,
variant,
allocationKey,
targetingKey,
errorMessage,
evalTimeMs,
observeFullEvaluationData,
attrs)
: new FlagEvalEvent(
Comment thread
danyal002 marked this conversation as resolved.
flagKey,
variant,
allocationKey,
targetingKey,
errorMessage,
evalTimeMs,
observeFullEvaluationData,
attrs,
features));
} catch (LinkageError e) {
// Never let EVP recording break flag evaluation
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package datadog.trace.api.openfeature;

import dev.openfeature.sdk.ImmutableMetadata;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

/**
* Reads the split features that {@link DDEvaluator} copies into flag metadata. It depends only on
* the OpenFeature API, so {@link ExposureHook} keeps working where the Datadog Java agent is
* absent.
*/
final class SplitFeatureMetadata {

private SplitFeatureMetadata() {}

/** The features stored in the metadata under {@code prefix}, keyed without the prefix. */
static Map<String, Object> featuresWithPrefix(
final ImmutableMetadata metadata, final String prefix) {
if (metadata == null) {
return Collections.emptyMap();
}
Map<String, Object> features = null;
for (final Map.Entry<String, Object> entry : metadata.asUnmodifiableMap().entrySet()) {
if (entry.getKey().startsWith(prefix)) {
if (features == null) {
features = new HashMap<>();
}
features.put(entry.getKey().substring(prefix.length()), entry.getValue());
}
}
return features == null
? Collections.<String, Object>emptyMap()
: Collections.unmodifiableMap(features);
}
}
Loading
Loading