Skip to content

Send EXPOSURE and EVALUATION feature flag features to Datadog - #12797

Merged
danyal002 merged 7 commits into
danyal.khan/EX-3815-exposure-hookfrom
danyal.khan/EX-3828
Oct 14, 2026
Merged

danyal002 merged 7 commits into
danyal.khan/EX-3815-exposure-hookfrom
danyal.khan/EX-3828

Conversation

@danyal002

@danyal002 danyal002 commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #12775.

What Does This Do

  • Routes UFC split features to each destination they list:
    • HOOK: __dd_feature.<key> in the flag metadata, as before.
    • EXPOSURE: __dd_exposure_feature.<key>, which ExposureLoggingHook puts on the exposure event as features.
    • EVALUATION: __dd_evaluation_feature.<key>, which FlagEvalLoggingHook puts on the flag-evaluation event.
  • A feature with several destinations is written once for each of them.
  • Adds a features field, with new constructors, to ExposureEvent and FlagEvalEvent. The existing constructors still work.
  • Flag-evaluation buckets now include the features, in both tiers. Evaluations with different features in one flush window give separate rows; evaluations with the same features merge as before. Each row serializes its features.

Motivation

Flag configurations can attach features to a split, for example a bandit policy ID, and send them with exposures and flag evaluations. This lets new features reach Datadog without an SDK release.

Additional Notes

  • Evaluation features are sent whatever the observeFullEvaluationData consent, because they come from the flag configuration, not the evaluation context.
  • Flushing is unchanged. Features only split a window's rows when they differ inside it.
  • Exposures and rows without features are byte-identical to today.
  • With an older dd-java-agent, the provider checks for the new constructors and sends events without features.
  • The intake schemas must accept features before any flag configuration uses these destinations: ddoghq/dd-source#124673.

Jira ticket: EX-3828

@danyal002 danyal002 added type: feature Enhancements and improvements comp: openfeature OpenFeature labels Oct 8, 2026
@datadog-prod-us1-4

This comment has been minimized.

@dd-octo-sts

dd-octo-sts Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

🟢 Java Benchmark SLOs — All performance SLOs passed

Suite Status
Startup 🟢 pass

SLO thresholds are defined here based on automatically generated metrics. A warning is raised when results are within 5% of the threshold.

PR vs. master results
Scenario Candidate master Δ (95% CI of mean)
startup:insecure-bank:iast:Agent 13.93 s 13.88 s [-0.1%; +0.9%] (no difference)
startup:insecure-bank:tracing:Agent 12.93 s 12.92 s [-0.8%; +1.0%] (no difference)
startup:petclinic:appsec:Agent 17.11 s 16.96 s [-0.0%; +1.7%] (no difference)
startup:petclinic:iast:Agent 16.83 s 17.01 s [-1.9%; -0.2%] (maybe better)
startup:petclinic:profiling:Agent 15.97 s 16.83 s [-9.1%; -1.1%] (significantly better)
startup:petclinic:sca:Agent 17.04 s 16.94 s [-0.4%; +1.5%] (no difference)
startup:petclinic:tracing:Agent 16.11 s 16.00 s [-0.2%; +1.6%] (no difference)

Commit: 19ded567 · CI Pipeline · Benchmarking Platform UI


Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion.

@danyal002
danyal002 force-pushed the danyal.khan/EX-3815-exposure-hook branch from 296c989 to 272a12d Compare October 8, 2026 19:03
@danyal002
danyal002 force-pushed the danyal.khan/EX-3828 branch from 112f76f to 5fc2740 Compare October 8, 2026 19:03
@danyal002
danyal002 marked this pull request as ready for review October 9, 2026 18:18
@danyal002
danyal002 requested a review from a team as a code owner October 9, 2026 18:18
@danyal002
danyal002 requested review from sameerank and typotter and removed request for a team October 9, 2026 18:18
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-09T18:25:47.852971Z 9a89416 Draft marked ready
🔒 Security Review ✅ Completed 2026-10-09T18:24:48.539728Z 9a89416 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9a89416f27

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +788 to +790
features = new HashMap<>();
}
features.put(entry.getKey().substring(prefix.length()), entry.getValue());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bound queued feature metadata

When an EVALUATION destination has many features or long feature keys, this rebuilds a new map and copies every stripped key for every evaluation, and FlagEvalLoggingHook then retains that map in the bounded-by-count handoff queue. Unlike evaluation context, the feature parser and this copy have no field-count or key-length limits, so a backed-up queue can retain thousands of unbounded copies and cause severe heap pressure or OOM; cap feature metadata similarly to context or precompute and safely reuse an immutable configuration-level map.

AGENTS.md reference: AGENTS.md:L81-L81

Useful? React with 👍 / 👎.

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.

The configuration schema bounds features: at most 32 per split, keys up to 256 characters, string values up to 1024 characters. The handoff queue is bounded by count, so its memory is bounded too.

This was replied to by Claude.

@datadog-prod-us1-4 datadog-prod-us1-4 Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bits Code Review: FAIL

On older agents lacking FlagEvalEvent, the unguarded class literal at line 76 of DDEvaluator.java throws NoClassDefFoundError before the try/catch in flagEvalFeaturesSupported() is reached, causing Provider.initialize() to report a FatalError and fully disabling flag evaluation instead of gracefully falling back without optional telemetry.

Open Bits AI session

🤖 Bits Code Review · Commit 9a89416

if (FEATURE_DESTINATION_HOOK.equals(destination)) {
return METADATA_FEATURE_PREFIX;
}
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 USE_LEGACY_EXPOSURE_API.get()
? new ExposureEvent(timestamp, allocation, exposureFlag, variant, subject)
: new ExposureEvent(timestamp, allocation, exposureFlag, variant, subject, serialId);
if (USE_LEGACY_EXPOSURE_API.get()) {

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:

[P3] Replace USE_LEGACY_EXPOSURE_API and EXPOSURE_FEATURES_SUPPORTED with one probed level of the agent's ExposureEvent API. This PR adds a third constructor, and exposureEvent() now reads two flags to choose among three constructors.

The two flags are probed separately, but they describe one ordered property: an agent with the features constructor also has the serial-id constructor. An enum with one value per agent generation (before 1.67.0, 1.67.0, and the release that adds features) says that directly. It gives tests one value to override, and each case documents the agent release it needs. This builds on the rename I suggested on #12774, and replaces that flag entirely.

Suggested change — one AtomicReference<ExposureEventApi>, same dispatch results (tested)
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
@@ -61,15 +61,32 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener {
   static final AtomicBoolean SPLIT_SERIAL_ID_SUPPORTED =
       new AtomicBoolean(splitSerialIdSupported(Split.class));
 
-  static final AtomicBoolean USE_LEGACY_EXPOSURE_API =
-      new AtomicBoolean(
-          !(SPLIT_SERIAL_ID_SUPPORTED.get() && exposureSerialIdSupported(ExposureEvent.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));
 
-  static final AtomicBoolean EXPOSURE_FEATURES_SUPPORTED =
-      new AtomicBoolean(exposureFeaturesSupported(ExposureEvent.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) {
@@ -771,7 +788,10 @@ 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 USE_LEGACY_EXPOSURE_API.get() || !SPLIT_SERIAL_ID_SUPPORTED.get() ? null : splitSerialId;
+    return EXPOSURE_EVENT_API.get() == ExposureEventApi.WITHOUT_SERIAL_ID
+            || !SPLIT_SERIAL_ID_SUPPORTED.get()
+        ? null
+        : splitSerialId;
   }
 
   private static Object resolveAttribute(final String name, final EvaluationContext context) {
@@ -858,10 +878,11 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener {
         new datadog.trace.api.featureflag.exposure.Variant(variantKey);
     final Subject subject = new Subject(context.getTargetingKey(), flattenContext(context));
 
-    if (USE_LEGACY_EXPOSURE_API.get()) {
+    final ExposureEventApi api = EXPOSURE_EVENT_API.get();
+    if (api == ExposureEventApi.WITHOUT_SERIAL_ID) {
       return new ExposureEvent(timestamp, allocation, exposureFlag, variant, subject);
     }
-    if (features.isEmpty() || !EXPOSURE_FEATURES_SUPPORTED.get()) {
+    if (api == ExposureEventApi.WITH_SERIAL_ID || features.isEmpty()) {
       return new ExposureEvent(timestamp, allocation, exposureFlag, variant, subject, serialId);
     }
     return new ExposureEvent(
diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorSpanEnrichmentForkedTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorSpanEnrichmentForkedTest.java
--- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorSpanEnrichmentForkedTest.java
+++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorSpanEnrichmentForkedTest.java
@@ -52,13 +52,14 @@ class DDEvaluatorSpanEnrichmentForkedTest {
    */
   @Test
   void enrichmentMetadataSurvivesAnAgentWithoutTheExposureConstructor() {
-    final boolean previous = DDEvaluator.USE_LEGACY_EXPOSURE_API.getAndSet(true);
+    final DDEvaluator.ExposureEventApi previous =
+        DDEvaluator.EXPOSURE_EVENT_API.getAndSet(DDEvaluator.ExposureEventApi.WITHOUT_SERIAL_ID);
     try {
       assertEquals(
           Integer.valueOf(340132),
           evaluate(340132).getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID));
     } finally {
-      DDEvaluator.USE_LEGACY_EXPOSURE_API.set(previous);
+      DDEvaluator.EXPOSURE_EVENT_API.set(previous);
     }
   }
 
diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java
--- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java
+++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java
@@ -420,11 +420,12 @@ public class DDEvaluatorTest {
 
   @Test
   public void legacyExposureApiDispatchesAnExposureWithoutSerialId() {
-    final boolean previous = DDEvaluator.USE_LEGACY_EXPOSURE_API.getAndSet(true);
+    final DDEvaluator.ExposureEventApi previous =
+        DDEvaluator.EXPOSURE_EVENT_API.getAndSet(DDEvaluator.ExposureEventApi.WITHOUT_SERIAL_ID);
     try {
       assertNull(exposureFor(7).serial_id);
     } finally {
-      DDEvaluator.USE_LEGACY_EXPOSURE_API.set(previous);
+      DDEvaluator.EXPOSURE_EVENT_API.set(previous);
     }
   }
 
@@ -458,7 +459,7 @@ public class DDEvaluatorTest {
     assertTrue(DDEvaluator.splitSerialIdSupported(Split.class));
     assertTrue(DDEvaluator.exposureSerialIdSupported(ExposureEvent.class));
     assertTrue(DDEvaluator.SPLIT_SERIAL_ID_SUPPORTED.get());
-    assertFalse(DDEvaluator.USE_LEGACY_EXPOSURE_API.get());
+    assertEquals(DDEvaluator.ExposureEventApi.WITH_FEATURES, DDEvaluator.EXPOSURE_EVENT_API.get());
   }
 
   @Test
@@ -487,6 +488,16 @@ public class DDEvaluatorTest {
     assertFalse(DDEvaluator.exposureSerialIdSupported(LegacyExposureEvent.class));
   }
 
+  @Test
+  public void exposuresHaveNoSerialIdWithoutBothTheSplitFieldAndTheEventConstructor() {
+    assertEquals(
+        DDEvaluator.ExposureEventApi.WITHOUT_SERIAL_ID,
+        DDEvaluator.exposureEventApi(true, LegacyExposureEvent.class));
+    assertEquals(
+        DDEvaluator.ExposureEventApi.WITHOUT_SERIAL_ID,
+        DDEvaluator.exposureEventApi(false, ExposureEvent.class));
+  }
+
   /**
    * Evaluates a logging allocation whose split carries the given serial id, then runs the exposure
    * hook as OpenFeature would after a successful evaluation, and returns the single dispatched
diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java
--- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java
+++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java
@@ -250,11 +250,12 @@ class ExposureHookTest {
         configuration(
             Boolean.TRUE,
             singletonList(new Feature("bandit.policy_id", "p2", singletonList("EXPOSURE")))));
-    DDEvaluator.EXPOSURE_FEATURES_SUPPORTED.set(false);
+    final DDEvaluator.ExposureEventApi previous =
+        DDEvaluator.EXPOSURE_EVENT_API.getAndSet(DDEvaluator.ExposureEventApi.WITH_SERIAL_ID);
     try {
       evaluate("user-1");
     } finally {
-      DDEvaluator.EXPOSURE_FEATURES_SUPPORTED.set(true);
+      DDEvaluator.EXPOSURE_EVENT_API.set(previous);
     }
 
     assertEquals(1, sentToDatadog.size());
@@ -265,6 +266,12 @@ class ExposureHookTest {
   void featureSupportChecksRejectEventsFromAnOlderAgent() {
     assertTrue(DDEvaluator.exposureFeaturesSupported(ExposureEvent.class));
     assertFalse(DDEvaluator.exposureFeaturesSupported(SerialIdExposureEvent.class));
+    assertEquals(
+        DDEvaluator.ExposureEventApi.WITH_FEATURES,
+        DDEvaluator.exposureEventApi(true, ExposureEvent.class));
+    assertEquals(
+        DDEvaluator.ExposureEventApi.WITH_SERIAL_ID,
+        DDEvaluator.exposureEventApi(true, SerialIdExposureEvent.class));
     assertTrue(FlagEvalLoggingHook.featuresSupported(FlagEvalEvent.class));
     assertFalse(FlagEvalLoggingHook.featuresSupported(LegacyFlagEvalEvent.class));
   }

On a copy of this PR's head:

  • :products:feature-flagging:feature-flagging-api:test passes 516 tests (515 plus one new test), forkedTest passes 4, and spotlessCheck passes.
  • exposureEvent() and exposureSerialId() give the same results as before for every combination of the old flags, including the tests that flip SPLIT_SERIAL_ID_SUPPORTED at runtime.
  • Only debug logging changes. On an agent without the serial-id constructor, the features probe no longer runs, so its debug line is not logged. The probes also run in a different order.

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, together with the probe consolidation.

This was replied to by Claude.

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.

return Collections.emptyMap();
}
Map<String, Object> features = null;
for (final Map.Entry<String, Object> entry : metadata.asUnmodifiableMap().entrySet()) {

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:

Heads up: this prefix scan works because Java's ImmutableMetadata can list its keys. The .NET and Kotlin (Android) OpenFeature SDKs cannot, so Datadog's providers there cannot copy this approach as is. It is worth sending those SDKs a patch now, so the support exists by the time we port this.

featuresWithPrefix walks metadata.asUnmodifiableMap().entrySet() to find the __dd_feature.*, __dd_exposure_feature.* and __dd_evaluation_feature.* keys. ExposureHook.Evaluation.getFeatures(), ExposureLoggingHook and FlagEvalLoggingHook all depend on it. In .NET and Kotlin, a hook can read a metadata value only when it already knows the key.

Evidence — which SDKs can list metadata keys, at the versions I checked
SDK (version checked; what Datadog's provider declares) Metadata type Can list keys?
.NET OpenFeature (2.3.0; dd-trace-dotnet references 2.3.0) ImmutableMetadata with only GetBool, GetInt, GetDouble and GetString. 2.12.0 added Count and IsEmpty. Unchanged at v2.14.1 and on main. No
Kotlin kotlin-sdk (0.6.2; dd-sdk-android declares 0.6.2) class EvaluationMetadata internal constructor(private val values: Map<String, Any>) with typed getters. main (v0.9.0) adds only getLong. No
Swift swift-sdk (0.3.1; the Swift provider allows 0.3.x) public var flagMetadata: [String: FlagMetadataValue] Yes
JS @openfeature/core (1.12.0) type FlagMetadata = Record<string, string | number | boolean> Yes
Go go-sdk (v1.18.0; dd-trace-go main requires v1.18.0) type FlagMetadata map[string]any Yes
Python openfeature-sdk (0.8.4; dd-trace-py allows >=0.8,<1) FlagMetadata = Mapping[str, ...], a dict by default Yes
Ruby openfeature-sdk (0.6.5; dd-trace-rb tests 0.5.1 to 0.6.5) a frozen Hash Yes

Where the language can mark data as immutable, the spec requires flag metadata to be immutable (1.4.15.1). It neither requires nor forbids listing its keys, so a read-only key view fits.

Suggested next steps
  • Upstream patches: a read-only key view on .NET ImmutableMetadata (for example IReadOnlyDictionary<string, object> or a Keys property), and on Kotlin EvaluationMetadata (for example asMap() or keys).
  • Until those ship and Datadog's providers can require them, a provider in those languages can stamp one extra key that lists the feature names, for example __dd_feature_keys = "holdout.key,holdout.assignment_group". Hooks then read each feature by name. That works on the SDK versions Datadog's providers use today.

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.

Noted for the .NET and Android ports. They need an OpenFeature SDK change, or another way to pass features, before we port this approach.

This was replied to by Claude.

variantKey,
context,
serialId,
DDEvaluator.featuresWithPrefix(

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:

Heads up: as written, exposure deduplication does not work with the bandit RFC, but that plan is still a draft. After a policy update, a subject that keeps its arm sends no exposure with the new bandit.policy_id, because both the provider cache and the agent's writer cache drop it.

The RFC sends bandit.policy_id as a split feature, and its pilot uses dd-trace-java. Three of its four options (A, B and C) keep the split salt stable across policy updates, so most returning subjects keep their arm. In its simulation, 10.6% to 26.2% of returning subjects switched arm under those options. Both caches compare only the allocation, variant and serial id for each flag and subject, and a policy update changes none of them. dd-source keys each serial id by allocation and variant (UpsertVAC), so a weight change keeps it. So while the cache keeps its entry, a subject that keeps its arm sends no exposure with the new policy id. The bandit pipeline plan starts each reward window at the subject's first exposure under each policy. Its first version reads exposures from the customer's warehouse (plan), so a customer ExposureHook that follows shouldSend() leaves the subject out of the new policy's data. Datadog's exposure intake has the same gap. Flag-evaluation rows do carry the new policy, because the aggregator keys rows by features.

Because the RFC can still change, please choose the fix with its authors. Two options work with the current draft:

  • Compare the EXPOSURE features in both caches. The tested change is below.
  • Add an allocation-level setting, such as alwaysLog, that the bandit sets so that its exposures skip deduplication. This is proposed in the RFC comments. It sends more exposures, but the pipeline plan already ignores repeated same-arm exposures under the same policy.
Evidence — measured at this PR's head, plus Eppo's behavior

Test: real OpenFeatureAPI, Provider and DDEvaluator. One allocation that logs exposures, arm A on every shard, serial id 7, and bandit.policy_id routed to EXPOSURE and EVALUATION. Evaluate user-1 under policy p1, dispatch the same configuration with p2, then evaluate user-1 and user-2.

What reached the listener Result
Exposures sent user-1@p1, user-2@p2 (nothing for user-1 under p2)
Flag-evaluation rows user-1@p1, user-1@p2, user-2@p2
ExposureHook.Evaluation.shouldSend() true, false, true

The agent side drops it as well: LRUExposureCache.add returns false for a second event that differs only in bandit.policy_id.

Eppo's bandit assignment cache also leaves the model version out of its key and value. But the Java server SDK defaults it to ExpiringInMemoryAssignmentCache(10, TimeUnit.MINUTES), so an entry expires after 10 minutes, and the subject's next bandit assignment after that is logged with the model version then in effect. Both caches in this stack are LRUs with no expiry.

Suggested change — compare the EXPOSURE features in both caches (tested)
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
@@ -661,9 +661,10 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener {
       }
       metadataBuilder.addBoolean(METADATA_DO_LOG, doLog);
     }
-    if (SPLIT_FEATURES_SUPPORTED.get()) {
-      addSplitFeatures(metadataBuilder, split);
-    }
+    final Map<String, Object> exposureFeatures =
+        SPLIT_FEATURES_SUPPORTED.get()
+            ? addSplitFeatures(metadataBuilder, split)
+            : Collections.<String, Object>emptyMap();
     if (doLog) {
       metadataBuilder.addBoolean(
           METADATA_EXPOSURE_CACHE_HIT,
@@ -672,7 +673,8 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener {
               context.getTargetingKey(),
               allocation.key,
               variant.key,
-              exposureSerialId(split.serialId)));
+              exposureSerialId(split.serialId),
+              exposureFeatures));
     }
     final ProviderEvaluation<T> result =
         ProviderEvaluation.<T>builder()
@@ -689,11 +691,13 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener {
     return result;
   }
 
-  private static void addSplitFeatures(
+  /** Adds the split's features to the metadata, and returns its EXPOSURE features. */
+  private static Map<String, Object> addSplitFeatures(
       final ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder, final Split split) {
     if (split.features == null) {
-      return;
+      return Collections.emptyMap();
     }
+    Map<String, Object> exposureFeatures = null;
     Set<String> seenKeys = null;
     for (final Object item : split.features) {
       if (!(item instanceof Feature)) {
@@ -712,12 +716,19 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener {
       for (final String destination : feature.destinations) {
         final String prefix = metadataPrefix(destination);
         if (prefix != null) {
-          addFeature(metadataBuilder, prefix + feature.key, feature.value);
+          final Object value = addFeature(metadataBuilder, prefix + feature.key, feature.value);
+          if (value != null && prefix == METADATA_EXPOSURE_FEATURE_PREFIX) {
+            if (exposureFeatures == null) {
+              exposureFeatures = new HashMap<>();
+            }
+            exposureFeatures.put(feature.key, value);
+          }
         } else if (WARNED_FEATURE_DESTINATIONS.add(destination)) {
           log.warn("Ignoring unknown feature destination {}", destination);
         }
       }
     }
+    return exposureFeatures == null ? Collections.<String, Object>emptyMap() : exposureFeatures;
   }
 
   private static String metadataPrefix(final String destination) {
@@ -733,17 +744,23 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener {
     return null;
   }
 
-  private static void addFeature(
+  /** Adds one feature to the metadata and returns the value as stored, or null if skipped. */
+  private static Object addFeature(
       final ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder,
       final String key,
       final Object value) {
     if (value instanceof String) {
       metadataBuilder.addString(key, (String) value);
+      return value;
     } else if (value instanceof Boolean) {
       metadataBuilder.addBoolean(key, (Boolean) value);
+      return value;
     } else if (value instanceof Number) {
-      metadataBuilder.addDouble(key, ((Number) value).doubleValue());
+      final Double stored = ((Number) value).doubleValue();
+      metadataBuilder.addDouble(key, stored);
+      return stored;
     }
+    return null;
   }
 
   /**
diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureDeduplicationCache.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureDeduplicationCache.java
--- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureDeduplicationCache.java
+++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureDeduplicationCache.java
@@ -27,8 +27,10 @@ final class ExposureDeduplicationCache {
       final String subject,
       final String allocation,
       final String variant,
-      final Integer serialId) {
-    return new Sent(allocation, variant, serialId).equals(entries.get(new Key(flag, subject)));
+      final Integer serialId,
+      final Map<String, Object> features) {
+    return new Sent(allocation, variant, serialId, features)
+        .equals(entries.get(new Key(flag, subject)));
   }
 
   synchronized void record(
@@ -36,8 +38,9 @@ final class ExposureDeduplicationCache {
       final String subject,
       final String allocation,
       final String variant,
-      final Integer serialId) {
-    entries.put(new Key(flag, subject), new Sent(allocation, variant, serialId));
+      final Integer serialId,
+      final Map<String, Object> features) {
+    entries.put(new Key(flag, subject), new Sent(allocation, variant, serialId, features));
   }
 
   synchronized int size() {
@@ -76,11 +79,18 @@ final class ExposureDeduplicationCache {
     private final String allocation;
     private final String variant;
     private final Integer serialId;
-
-    Sent(final String allocation, final String variant, final Integer serialId) {
+    // EXPOSURE features, such as bandit.policy_id: a change sends the exposure again.
+    private final Map<String, Object> features;
+
+    Sent(
+        final String allocation,
+        final String variant,
+        final Integer serialId,
+        final Map<String, Object> features) {
       this.allocation = allocation;
       this.variant = variant;
       this.serialId = serialId;
+      this.features = features;
     }
 
     @Override
@@ -91,12 +101,13 @@ final class ExposureDeduplicationCache {
       final Sent entry = (Sent) o;
       return Objects.equals(allocation, entry.allocation)
           && Objects.equals(variant, entry.variant)
-          && Objects.equals(serialId, entry.serialId);
+          && Objects.equals(serialId, entry.serialId)
+          && Objects.equals(features, entry.features);
     }
 
     @Override
     public int hashCode() {
-      return Objects.hash(allocation, variant, serialId);
+      return Objects.hash(allocation, variant, serialId, features);
     }
   }
 
diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureLoggingHook.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureLoggingHook.java
--- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureLoggingHook.java
+++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureLoggingHook.java
@@ -56,17 +56,14 @@ class ExposureLoggingHook<T> implements Hook<T> {
       }
       final Integer serialId =
           DDEvaluator.exposureSerialId(metadata.getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID));
-      cache.record(flag, context.getTargetingKey(), allocationKey, variantKey, serialId);
+      final Map<String, Object> exposureFeatures =
+          DDEvaluator.featuresWithPrefix(metadata, DDEvaluator.METADATA_EXPOSURE_FEATURE_PREFIX);
+      cache.record(
+          flag, context.getTargetingKey(), allocationKey, variantKey, serialId, exposureFeatures);
       if (sendToDatadog) {
         FeatureFlaggingGateway.dispatch(
             DDEvaluator.exposureEvent(
-                flag,
-                allocationKey,
-                variantKey,
-                context,
-                serialId,
-                DDEvaluator.featuresWithPrefix(
-                    metadata, DDEvaluator.METADATA_EXPOSURE_FEATURE_PREFIX)));
+                flag, allocationKey, variantKey, context, serialId, exposureFeatures));
       }
     } catch (LinkageError e) {
       // Never let exposure reporting break flag evaluation.
diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureLoggingHookTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureLoggingHookTest.java
--- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureLoggingHookTest.java
+++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureLoggingHookTest.java
@@ -164,15 +164,15 @@ class ExposureLoggingHookTest {
   @Test
   void cacheEvictsTheLeastRecentlyUsedSubject() {
     final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(2);
-    cache.record("flag", "user-1", "allocation", "on", 7);
-    cache.record("flag", "user-2", "allocation", "on", 7);
-    assertTrue(cache.contains("flag", "user-1", "allocation", "on", 7));
-    cache.record("flag", "user-3", "allocation", "on", 7);
-
-    assertTrue(cache.contains("flag", "user-1", "allocation", "on", 7));
-    assertFalse(cache.contains("flag", "user-2", "allocation", "on", 7));
-    assertTrue(cache.contains("flag", "user-3", "allocation", "on", 7));
-    assertFalse(cache.contains("flag", "user-1", "allocation", "on", 8));
+    cache.record("flag", "user-1", "allocation", "on", 7, emptyMap());
+    cache.record("flag", "user-2", "allocation", "on", 7, emptyMap());
+    assertTrue(cache.contains("flag", "user-1", "allocation", "on", 7, emptyMap()));
+    cache.record("flag", "user-3", "allocation", "on", 7, emptyMap());
+
+    assertTrue(cache.contains("flag", "user-1", "allocation", "on", 7, emptyMap()));
+    assertFalse(cache.contains("flag", "user-2", "allocation", "on", 7, emptyMap()));
+    assertTrue(cache.contains("flag", "user-3", "allocation", "on", 7, emptyMap()));
+    assertFalse(cache.contains("flag", "user-1", "allocation", "on", 8, emptyMap()));
   }
 
   private static HookContext<Object> hookContext(final MutableContext context) {
diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java
--- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java
+++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java
@@ -1,6 +1,7 @@
 package com.datadog.featureflag;
 
 import datadog.trace.api.featureflag.exposure.ExposureEvent;
+import java.util.Map;
 import java.util.Objects;
 
 public interface ExposureCache {
@@ -39,11 +40,14 @@ public interface ExposureCache {
     public final String variant;
     public final String allocation;
     public final Integer serialId;
+    // EXPOSURE features, such as bandit.policy_id: a change sends the exposure again.
+    public final Map<String, Object> features;
 
     public Value(final ExposureEvent event) {
       this.variant = event.variant == null ? null : event.variant.key;
       this.allocation = event.allocation == null ? null : event.allocation.key;
       this.serialId = event.serial_id;
+      this.features = event.features == null || event.features.isEmpty() ? null : event.features;
     }
 
     @Override
@@ -54,12 +58,13 @@ public interface ExposureCache {
       final Value value = (Value) o;
       return Objects.equals(variant, value.variant)
           && Objects.equals(allocation, value.allocation)
-          && Objects.equals(serialId, value.serialId);
+          && Objects.equals(serialId, value.serialId)
+          && Objects.equals(features, value.features);
     }
 
     @Override
     public int hashCode() {
-      return Objects.hash(variant, allocation, serialId);
+      return Objects.hash(variant, allocation, serialId, features);
     }
   }
 }

With this change, the same test sends user-1@p1, user-1@p2, user-2@p2, shouldSend() returns true, true, true, and the agent's writer cache sends the p2 event. On a copy of this PR's head, :products:feature-flagging:feature-flagging-api:test passes 517 tests, :products:feature-flagging:feature-flagging-lib:test passes 298, and forkedTest passes 4, with 0 failures. That includes the two tests that assert the new behavior, and spotlessCheck passes.

Trade-offs: each cache entry now also keeps the EXPOSURE feature map, and any change to an EXPOSURE feature sends the exposure again.

Why not a new serial id for each policy

A new serial id for each policy would also make both caches send again, but we do not recommend it. Each serial id is an immutable VAC row (model) and a FRAMES context entry, which APM processing needs to decode the serial ids that spans carry (APM enricher plan). A 20-arm bandit that updates every hour would add 480 of each every day, and VAC rows have no delete or deactivate field.

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. The plan is to give each bandit policy its own exposure context: the exposure_contexts table leaves room for bandit_policy_id. A policy update then gets a new serial id, and both caches resend without an SDK change.

This was replied to by Claude.

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:

Heads up: a new exposure context for each policy fixes the deduplication gap with no SDK change, so this PR needs nothing for it. It needs dd-java-agent 1.67.0 or later, where the serial id counts toward deduplication. Aaron has raised a concern about that plan on the bandit RFC, so it can still change.

The dd-source plan for EX-3823 inserts a new policy, exposure context and serial ids on every successful refresh, even when the weights do not change. Each serial id is an immutable VAC row, and APM processing resolves serial ids through FRAMES. So a 20-arm bandit that updates every hour adds 480 serial ids a day. The RFC comment suggests an allocation-level setting such as alwaysLog for bandit allocations instead. If the RFC moves to that, the provider and the agent would need to skip deduplication for those allocations, in both caches and in shouldSend().

} 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.

@danyal002
danyal002 force-pushed the danyal.khan/EX-3815-exposure-hook branch from a7d2222 to 4a665f2 Compare October 13, 2026 14:31
@danyal002
danyal002 requested a review from a team as a code owner October 13, 2026 14:31
@danyal002
danyal002 requested review from amarziali and removed request for a team October 13, 2026 14:31

@datadog-prod-us1-4 datadog-prod-us1-4 Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bits Code Review: PASS

More details

Different evaluation feature sets remain separate in both aggregation tiers, and constructor probes preserve fallback behavior with older agents. Intake schema readiness remains an unverified rollout prerequisite.

Was this helpful? React 👍 or 👎

Open Bits AI session

🤖 Bits Code Review · Commit bc1cdd4

@danyal002
danyal002 force-pushed the danyal.khan/EX-3828 branch from bc1cdd4 to 9d01a03 Compare October 13, 2026 15:02

@aarsilv aarsilv left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for iterating!

A few more minor things to consider, but overall this has shaped up well!

variantKey,
context,
serialId,
DDEvaluator.featuresWithPrefix(

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:

Heads up: a new exposure context for each policy fixes the deduplication gap with no SDK change, so this PR needs nothing for it. It needs dd-java-agent 1.67.0 or later, where the serial id counts toward deduplication. Aaron has raised a concern about that plan on the bandit RFC, so it can still change.

The dd-source plan for EX-3823 inserts a new policy, exposure context and serial ids on every successful refresh, even when the weights do not change. Each serial id is an immutable VAC row, and APM processing resolves serial ids through FRAMES. So a 20-arm bandit that updates every hour adds 480 serial ids a day. The RFC comment suggests an allocation-level setting such as alwaysLog for bandit allocations instead. If the RFC moves to that, the provider and the agent would need to skip deduplication for those allocations, in both caches and in shouldSend().

A UFC split feature can list the EXPOSURE and EVALUATION destinations.
The evaluator now writes each feature once for each destination it
lists, under that destination's metadata prefix. The exposure hook puts
EXPOSURE features on the exposure event, and the flag-evaluation hook
puts EVALUATION features on the flag-evaluation event, whatever the
consent, because they come from the flag configuration.

Flag-evaluation buckets now include the features in both tiers, so rows
with different features never merge, and each row serializes them. Both
events gain a features field with new constructors; with an older agent
the provider sends them without features.
The FFE_FLAGS schema documents that keys are unique within a split and
that SDKs keep the first feature with a repeated key. Flag metadata is a
map, so the provider used to keep the last one. It now ignores a later
feature with a key it has already seen, for every destination.
DDEvaluator resolved FlagEvalEvent.class in its static initializer.
Agents released before FlagEvalEvent existed then failed DDEvaluator's
initialization with NoClassDefFoundError, which broke the provider. The
check now lives in FlagEvalLoggingHook, the only class that uses it, and
Provider already guards that hook's loading against LinkageError.
- Moves featuresWithPrefix into SplitFeatureMetadata, which depends only
  on the OpenFeature API. ExposureHook.getFeatures() called it on
  DDEvaluator, which needs the Datadog Java agent, so it threw
  NoClassDefFoundError where the agent is absent, such as unit tests.
- DDEvaluator marks the metadata with __dd_evaluation_features only when
  the split routes a feature to EVALUATION. FlagEvalLoggingHook scans
  the metadata only then, instead of on every evaluation.
- Adds an end-to-end test of EVALUATION features for an allocation that
  does not log exposures. Every earlier provider-backed test used
  doLog=true, so adding features only for logged allocations passed.
- Reflows a broken comment.
Each bucket kept the features map that the provider built for its own
evaluation, and each key kept its own canonical string. Full-tier keys
include the targeting key, so up to PER_FLAG_CAP subjects of one split
held that many equal maps and strings until the flush. The aggregator now
keeps one key and map for each distinct feature set, and clears them with
the rest of the window.
The test classpath always has the agent classes, so no other test sees
ExposureHook reach for them. This test loads ExposureHook in a class
loader that hides DDEvaluator and the agent classes, as in an
application without the agent.
- FlagEvalLoggingHook reads allocationKey, the evaluation timestamp,
  the consent key and the evaluation-features marker from one map. The
  ImmutableMetadata getters build a debug message for every absent key,
  and error and default results carry only the consent key.
- Renames the marker to __dd_has_evaluation_features, so that it does
  not differ from the __dd_evaluation_feature. prefix by one character.
- Rewraps a comment that google-java-format had split.
@danyal002
danyal002 force-pushed the danyal.khan/EX-3828 branch from b747e15 to bd4c417 Compare October 14, 2026 13:59
@danyal002
danyal002 merged commit c2e1838 into danyal.khan/EX-3815-exposure-hook Oct 14, 2026
3 of 4 checks passed
@danyal002
danyal002 deleted the danyal.khan/EX-3828 branch October 14, 2026 14:00
danyal002 added a commit that referenced this pull request Oct 14, 2026
)

* Parse features on UFC splits

A UFC split can carry a list of key-value features for exposure hooks.
Split gains a nullable features field, and the parser reads it leniently:
a malformed list or entry is dropped instead of rejecting the flag, so
features never change which variation a subject gets.

* Cover the features adapter factory and exclude the Feature POJO from coverage

Test the factory's type and annotation checks and the read-only adapter,
as the other UFC adapters do, and list the Feature model class with the
other UFC POJOs that are excluded from coverage verification.

* Let applications record feature flag exposures with ExposureHook

Add a public ExposureHook that hands each completed evaluation to a
callback with the provider's exposure decision. shouldSend() is true for
exactly the exposures the provider sends to Datadog. getFeatures()
returns the selected split's features, which the provider now copies
into the flag metadata as __dd_feature.<key>.

DD_FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED=false stops sending
exposures to Datadog. The exposure cache keeps running, so ExposureHook
callbacks get the same decisions either way.

* Parse the destinations of UFC split features

Each feature now lists where SDKs deliver it, such as HOOK, EXPOSURE or
EVALUATION. Destinations are open strings: the parser keeps every
non-empty string, including ones this SDK does not know, and drops a
feature that has no destinations.

* Deliver only HOOK features to exposure hooks

Copy a split feature into the __dd_feature.<key> metadata only when its
destinations include HOOK. EXPOSURE and EVALUATION are known
destinations that are not delivered to hooks. A destination the SDK does
not know is ignored, with one warning for each new name.

* Replace getCacheHit() with a primitive isCacheHit()

SpotBugs reports NP_BOOLEAN_RETURN_NULL because getCacheHit() returned
a Boolean that is null when the evaluation is not an exposure. A caller
that unboxes it gets a NullPointerException. isCacheHit() returns false
in that case; isExposure() still tells callers whether it is one.
shouldSend() still needs an explicit false stamp, so sending is
unchanged.

* Cap the unknown feature destinations the evaluator remembers

The warn-once set kept every unknown destination name for the life of
the application, so a stream of configurations with new names grew it
without bound. It now stops at 64 names; later unknown destinations are
still ignored, only without a warning.

* State when shouldSend() matches Datadog, and surface hook failures

shouldSend() matches the exposures Datadog receives only when the
provider's own hooks run. The Javadoc now says so, names the
MultiProvider case before OpenFeature SDK 1.23.0, and says that
ExposureHook is optional because the flag metadata carries the same data.

ExposureHook now skips the callback when OpenFeature passes no details,
and logs the first callback failure at warn level instead of debug.
ExposureLoggingHook logs its first LinkageError at warn level instead of
swallowing it. Both read flag metadata through its map, because the
ImmutableMetadata getters build a debug message for every absent key.

* Test that the Datadog exposure logging setting turns sending off

The earlier tests passed false straight to the gate or the hook, so a
wrong key would still pass. This forked test sets the real system
property and checks that the provider sends nothing to Datadog while
ExposureHook keeps its decisions.

* Lead the exposure README with the flag metadata any hook can read

The README now says first that the provider puts its exposure decision
and the split's features in the flag metadata, and that ExposureHook is a
convenience on top of it. It says how to read the metadata, narrows the
shouldSend() claim to when the provider's hooks run, corrects the
__dd_do_log and __dd_split_serial_id rows, and adds a table of the
dd-java-agent version each capability needs.

* Test evaluation with an agent whose Split has no features

No released dd-java-agent has Split.features yet. The provider must
skip reading it there, or every evaluation throws NoSuchFieldError.

* Correct when shouldSend() matches what Datadog receives

It matches only while Datadog exposure logging is on, and a changed
serial id counts only with dd-java-agent 1.67.0 or later. The README
also gives 1.68.0 for split features, says that the logging setting
does not stop flag evaluation events, and that the provider does not
read dd-trace-py's DD_FFE_INTAKE_ENABLED.

* Send EXPOSURE and EVALUATION feature flag features to Datadog (#12797)

* Send EXPOSURE and EVALUATION features to Datadog

A UFC split feature can list the EXPOSURE and EVALUATION destinations.
The evaluator now writes each feature once for each destination it
lists, under that destination's metadata prefix. The exposure hook puts
EXPOSURE features on the exposure event, and the flag-evaluation hook
puts EVALUATION features on the flag-evaluation event, whatever the
consent, because they come from the flag configuration.

Flag-evaluation buckets now include the features in both tiers, so rows
with different features never merge, and each row serializes them. Both
events gain a features field with new constructors; with an older agent
the provider sends them without features.

* Keep the first feature when a split repeats a feature key

The FFE_FLAGS schema documents that keys are unique within a split and
that SDKs keep the first feature with a repeated key. Flag metadata is a
map, so the provider used to keep the last one. It now ignores a later
feature with a key it has already seen, for every destination.

* Check flag-evaluation feature support inside FlagEvalLoggingHook

DDEvaluator resolved FlagEvalEvent.class in its static initializer.
Agents released before FlagEvalEvent existed then failed DDEvaluator's
initialization with NoClassDefFoundError, which broke the provider. The
check now lives in FlagEvalLoggingHook, the only class that uses it, and
Provider already guards that hook's loading against LinkageError.

* Read split features without DDEvaluator, and scan only when needed

- Moves featuresWithPrefix into SplitFeatureMetadata, which depends only
  on the OpenFeature API. ExposureHook.getFeatures() called it on
  DDEvaluator, which needs the Datadog Java agent, so it threw
  NoClassDefFoundError where the agent is absent, such as unit tests.
- DDEvaluator marks the metadata with __dd_evaluation_features only when
  the split routes a feature to EVALUATION. FlagEvalLoggingHook scans
  the metadata only then, instead of on every evaluation.
- Adds an end-to-end test of EVALUATION features for an allocation that
  does not log exposures. Every earlier provider-backed test used
  doLog=true, so adding features only for logged allocations passed.
- Reflows a broken comment.

* Share one copy of each feature set across a flush window

Each bucket kept the features map that the provider built for its own
evaluation, and each key kept its own canonical string. Full-tier keys
include the targeting key, so up to PER_FLAG_CAP subjects of one split
held that many equal maps and strings until the flush. The aggregator now
keeps one key and map for each distinct feature set, and clears them with
the rest of the window.

* Test that ExposureHook works without the Datadog Java agent

The test classpath always has the agent classes, so no other test sees
ExposureHook reach for them. This test loads ExposureHook in a class
loader that hides DDEvaluator and the agent classes, as in an
application without the agent.

* Read flag evaluation metadata from its map, and rename the marker

- FlagEvalLoggingHook reads allocationKey, the evaluation timestamp,
  the consent key and the evaluation-features marker from one map. The
  ImmutableMetadata getters build a debug message for every absent key,
  and error and default results carry only the consent key.
- Renames the marker to __dd_has_evaluation_features, so that it does
  not differ from the __dd_evaluation_feature. prefix by one character.
- Rewraps a comment that google-java-format had split.
gh-worker-dd-mergequeue-cf854d Bot pushed a commit that referenced this pull request Oct 14, 2026
…cord them with ExposureHook (#12774)

Send exposures only after a successful evaluation

The provider now deduplicates exposures itself and sends them from a
finally-stage hook instead of at resolution. Resolution only checks the
new provider cache and stamps __dd_exposure_cache_hit in the flag
metadata. The hook records the exposure and sends it when the allocation
logs exposures, the subject is not already exposed, and the evaluation
has no error, so an evaluation that an application hook fails no longer
sends an exposure or marks the subject as exposed.

The agent's writer keeps its own cache for now, because an older
provider still sends every evaluation.

Cover the exposure hook guards and exclude the cache POJOs from coverage

Test each condition under which the exposure hook sends nothing, and
list the exposure cache's key and value classes as POJOs excluded from
coverage verification, as the writer's cache classes are.

Bound the exposure cache with a static map class

SpotBugs reports IA_AMBIGUOUS_INVOCATION_OF_INHERITED_OR_OUTER_METHOD
because the anonymous LinkedHashMap's size() call could resolve to the
cache's own size(). A static nested map class has no outer instance, so
the call is unambiguous.

Explain why the exposure cache value classes skip coverage

Read the split serial id only when the agent's Split has it

The exposure cache check read split.serialId before exposureSerialId
could apply SPLIT_SERIAL_ID_SUPPORTED. With an older dd-java-agent whose
Split has no serialId field, every evaluation of an allocation that logs
exposures then threw NoSuchFieldError. The read now sits behind the same
guard as the other serial id reads.

Require OpenFeature SDK 1.23.0 so exposures work inside a MultiProvider

Exposures are now sent from a provider hook. OpenFeature SDK 1.20.1 to
1.22.1 does not run the hooks of providers wrapped in a MultiProvider, so
a Datadog provider inside one sent no exposures. SDK 1.23.0 runs them.
The README states 1.23.0 as the minimum for MultiProvider, and a test
covers the MultiProvider path; it fails on 1.20.1.

Read exposure metadata from its map in ExposureLoggingHook

The ImmutableMetadata getters build a debug message for every absent key,
whether or not debug logging is on. __dd_do_log is absent from most
evaluations by default, so the hook built that string on most of them. It
now reads the keys from asUnmodifiableMap().

Rename USE_LEGACY_EXPOSURE_API to EXPOSURE_SERIAL_ID_SUPPORTED

The flag now names the capability it detects, like SPLIT_SERIAL_ID_SUPPORTED
next to it, instead of the case to avoid. Its value is inverted, and every
reader is inverted with it.

Correct how the provider and writer exposure caches relate

They have the same size but refresh entries at different times, so once
full they can evict different subjects. The comment said they evict the
same ones.

Test deduplication with an agent that predates serial-id exposures

On dd-java-agent 1.65 and 1.66 the lookup at resolution uses no serial
id, so the hook must record none either. Otherwise every repeat of a
split with a serial id is sent again.

Rewrap the exposure cache size comment

google-java-format had split it, leaving a line that held only one word.

Let applications record feature flag exposures with ExposureHook (#12775)

* Parse features on UFC splits

A UFC split can carry a list of key-value features for exposure hooks.
Split gains a nullable features field, and the parser reads it leniently:
a malformed list or entry is dropped instead of rejecting the flag, so
features never change which variation a subject gets.

* Cover the features adapter factory and exclude the Feature POJO from coverage

Test the factory's type and annotation checks and the read-only adapter,
as the other UFC adapters do, and list the Feature model class with the
other UFC POJOs that are excluded from coverage verification.

* Let applications record feature flag exposures with ExposureHook

Add a public ExposureHook that hands each completed evaluation to a
callback with the provider's exposure decision. shouldSend() is true for
exactly the exposures the provider sends to Datadog. getFeatures()
returns the selected split's features, which the provider now copies
into the flag metadata as __dd_feature.<key>.

DD_FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED=false stops sending
exposures to Datadog. The exposure cache keeps running, so ExposureHook
callbacks get the same decisions either way.

* Parse the destinations of UFC split features

Each feature now lists where SDKs deliver it, such as HOOK, EXPOSURE or
EVALUATION. Destinations are open strings: the parser keeps every
non-empty string, including ones this SDK does not know, and drops a
feature that has no destinations.

* Deliver only HOOK features to exposure hooks

Copy a split feature into the __dd_feature.<key> metadata only when its
destinations include HOOK. EXPOSURE and EVALUATION are known
destinations that are not delivered to hooks. A destination the SDK does
not know is ignored, with one warning for each new name.

* Replace getCacheHit() with a primitive isCacheHit()

SpotBugs reports NP_BOOLEAN_RETURN_NULL because getCacheHit() returned
a Boolean that is null when the evaluation is not an exposure. A caller
that unboxes it gets a NullPointerException. isCacheHit() returns false
in that case; isExposure() still tells callers whether it is one.
shouldSend() still needs an explicit false stamp, so sending is
unchanged.

* Cap the unknown feature destinations the evaluator remembers

The warn-once set kept every unknown destination name for the life of
the application, so a stream of configurations with new names grew it
without bound. It now stops at 64 names; later unknown destinations are
still ignored, only without a warning.

* State when shouldSend() matches Datadog, and surface hook failures

shouldSend() matches the exposures Datadog receives only when the
provider's own hooks run. The Javadoc now says so, names the
MultiProvider case before OpenFeature SDK 1.23.0, and says that
ExposureHook is optional because the flag metadata carries the same data.

ExposureHook now skips the callback when OpenFeature passes no details,
and logs the first callback failure at warn level instead of debug.
ExposureLoggingHook logs its first LinkageError at warn level instead of
swallowing it. Both read flag metadata through its map, because the
ImmutableMetadata getters build a debug message for every absent key.

* Test that the Datadog exposure logging setting turns sending off

The earlier tests passed false straight to the gate or the hook, so a
wrong key would still pass. This forked test sets the real system
property and checks that the provider sends nothing to Datadog while
ExposureHook keeps its decisions.

* Lead the exposure README with the flag metadata any hook can read

The README now says first that the provider puts its exposure decision
and the split's features in the flag metadata, and that ExposureHook is a
convenience on top of it. It says how to read the metadata, narrows the
shouldSend() claim to when the provider's hooks run, corrects the
__dd_do_log and __dd_split_serial_id rows, and adds a table of the
dd-java-agent version each capability needs.

* Test evaluation with an agent whose Split has no features

No released dd-java-agent has Split.features yet. The provider must
skip reading it there, or every evaluation throws NoSuchFieldError.

* Correct when shouldSend() matches what Datadog receives

It matches only while Datadog exposure logging is on, and a changed
serial id counts only with dd-java-agent 1.67.0 or later. The README
also gives 1.68.0 for split features, says that the logging setting
does not stop flag evaluation events, and that the provider does not
read dd-trace-py's DD_FFE_INTAKE_ENABLED.

* Send EXPOSURE and EVALUATION feature flag features to Datadog (#12797)

* Send EXPOSURE and EVALUATION features to Datadog

A UFC split feature can list the EXPOSURE and EVALUATION destinations.
The evaluator now writes each feature once for each destination it
lists, under that destination's metadata prefix. The exposure hook puts
EXPOSURE features on the exposure event, and the flag-evaluation hook
puts EVALUATION features on the flag-evaluation event, whatever the
consent, because they come from the flag configuration.

Flag-evaluation buckets now include the features in both tiers, so rows
with different features never merge, and each row serializes them. Both
events gain a features field with new constructors; with an older agent
the provider sends them without features.

* Keep the first feature when a split repeats a feature key

The FFE_FLAGS schema documents that keys are unique within a split and
that SDKs keep the first feature with a repeated key. Flag metadata is a
map, so the provider used to keep the last one. It now ignores a later
feature with a key it has already seen, for every destination.

* Check flag-evaluation feature support inside FlagEvalLoggingHook

DDEvaluator resolved FlagEvalEvent.class in its static initializer.
Agents released before FlagEvalEvent existed then failed DDEvaluator's
initialization with NoClassDefFoundError, which broke the provider. The
check now lives in FlagEvalLoggingHook, the only class that uses it, and
Provider already guards that hook's loading against LinkageError.

* Read split features without DDEvaluator, and scan only when needed

- Moves featuresWithPrefix into SplitFeatureMetadata, which depends only
  on the OpenFeature API. ExposureHook.getFeatures() called it on
  DDEvaluator, which needs the Datadog Java agent, so it threw
  NoClassDefFoundError where the agent is absent, such as unit tests.
- DDEvaluator marks the metadata with __dd_evaluation_features only when
  the split routes a feature to EVALUATION. FlagEvalLoggingHook scans
  the metadata only then, instead of on every evaluation.
- Adds an end-to-end test of EVALUATION features for an allocation that
  does not log exposures. Every earlier provider-backed test used
  doLog=true, so adding features only for logged allocations passed.
- Reflows a broken comment.

* Share one copy of each feature set across a flush window

Each bucket kept the features map that the provider built for its own
evaluation, and each key kept its own canonical string. Full-tier keys
include the targeting key, so up to PER_FLAG_CAP subjects of one split
held that many equal maps and strings until the flush. The aggregator now
keeps one key and map for each distinct feature set, and clears them with
the rest of the window.

* Test that ExposureHook works without the Datadog Java agent

The test classpath always has the agent classes, so no other test sees
ExposureHook reach for them. This test loads ExposureHook in a class
loader that hides DDEvaluator and the agent classes, as in an
application without the agent.

* Read flag evaluation metadata from its map, and rename the marker

- FlagEvalLoggingHook reads allocationKey, the evaluation timestamp,
  the consent key and the evaluation-features marker from one map. The
  ImmutableMetadata getters build a debug message for every absent key,
  and error and default results carry only the consent key.
- Renames the marker to __dd_has_evaluation_features, so that it does
  not differ from the __dd_evaluation_feature. prefix by one character.
- Rewraps a comment that google-java-format had split.

Co-authored-by: devflow.devflow-routing-intake <devflow.devflow-routing-intake@kubernetes.us1.ddbuild.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp: openfeature OpenFeature type: feature Enhancements and improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants