Repository navigation
Send EXPOSURE and EVALUATION feature flag features to Datadog - #12797
Conversation
This comment has been minimized.
This comment has been minimized.
🟢 Java Benchmark SLOs — All performance SLOs passed
PR vs. master results
Commit: Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion. |
296c989 to
272a12d
Compare
112f76f to
5fc2740
Compare
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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".
| features = new HashMap<>(); | ||
| } | ||
| features.put(entry.getKey().substring(prefix.length()), entry.getValue()); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
🤖 Bits Code Review · Commit 9a89416
| if (FEATURE_DESTINATION_HOOK.equals(destination)) { | ||
| return METADATA_FEATURE_PREFIX; | ||
| } | ||
| if (FEATURE_DESTINATION_EXPOSURE.equals(destination)) { |
There was a problem hiding this comment.
🤖 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
featurestoexposure.json,batched_exposures.json,flagevaluation.jsonandbatchedflagevaluations.json. exposures-worker/schemas/batched_exposures.json: eachexposuresitem allows onlyallocation,flag,serial_id,subject,timestampandvariant, withadditionalProperties: false. Inflageval-worker/schemas/batchedflagevaluations.json, eachflagEvaluationsitem hasadditionalProperties: falseand nofeatures.exposures-worker/src/worker.ts,getExposures: forapplication/jsonit callsdecodeJson(event.payload, BatchedExposuresSchema).processcatches the error and returnsdrop(dropReasons.invalidEvent)for the whole event.flageval-worker/src/worker.tsdoes the same.- The stdlib
decodeJsonthrows when the schema check fails. With the ajv validator that is aSchemaValidationError. exposures-worker/src/worker.test.tsalready notes the mechanism: "Without serial_id in the schema, additionalProperties:false would have rejected the whole event."- In this repo,
FeatureFlagEvpPublisherposts withMediaType.parse("application/json"). remote-config/shared/libs/config-validator/schemas/FFE_FLAGS/root.json(commit7ac915de08, on main) says ofdestinations: "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.There was a problem hiding this comment.
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()) { |
There was a problem hiding this comment.
🤖 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:testpasses 516 tests (515 plus one new test),forkedTestpasses 4, andspotlessCheckpasses.exposureEvent()andexposureSerialId()give the same results as before for every combination of the old flags, including the tests that flipSPLIT_SERIAL_ID_SUPPORTEDat 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.
There was a problem hiding this comment.
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 = |
There was a problem hiding this comment.
🤖 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:testpasses 517 tests,forkedTestpasses 4, andspotlessCheckpasses. AgentSupportTestloads the provider classes through a class loader that hidesFlagEvalEvent, the class that agents before 1.66.0 lack. It covers that missing class only, not every other difference in an older agent.DDEvaluatorandAgentSupportstill initialize, andEXPOSURE_EVENT_APIisWITH_FEATURES. InitializingAgentSupport$FlagEvalEventsin that loader throwsNoClassDefFoundError, as a positive control.- Mutant 1: a
FlagEvalEventprobe added toAgentSupport's static initializer makes the test fail withNoClassDefFoundErroratAgentSupport.<clinit>. - Mutant 2: the probe moved back into
DDEvaluator's static initializer, the original bug, makes it fail atDDEvaluator.<clinit>. javapon the compiled classes:AgentSupport.classandDDEvaluator.classhave no reference toFlagEvalEvent.AgentSupport$FlagEvalEvents.classhas 3.
Two behavior changes:
- The probes run later, on the first read of an
AgentSupportfield. For theSplitandExposureEventprobes, that is the first evaluation that resolves a variant, or the first exposure built; before, they ran whenDDEvaluatorloaded. For theFlagEvalEventprobe, it is the first timeFlagEvalLoggingHookbuilds 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 anAgentSupportfield inDDEvaluator's static initializer, and anAgentSupport.FlagEvalEventsfield inFlagEvalLoggingHook's. - The probe log lines come from the
AgentSupportlogger instead of theDDEvaluatorandFlagEvalLoggingHookloggers.
There was a problem hiding this comment.
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()) { |
There was a problem hiding this comment.
🤖 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 exampleIReadOnlyDictionary<string, object>or aKeysproperty), and on KotlinEvaluationMetadata(for exampleasMap()orkeys). - 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.
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
🤖 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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()); |
There was a problem hiding this comment.
🤖 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": 42parses toDouble 42.0, and"value": 10000000toDouble 1.0E7."value": "p1"stays aString. FeatureFlagEvpPublisherserializes 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.
There was a problem hiding this comment.
A string policy id avoids this. I will raise it on the bandit RFC.
This was replied to by Claude.
a7d2222 to
4a665f2
Compare
There was a problem hiding this comment.
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.
🤖 Bits Code Review · Commit bc1cdd4
bc1cdd4 to
9d01a03
Compare
aarsilv
left a comment
There was a problem hiding this comment.
Thanks for iterating!
A few more minor things to consider, but overall this has shaped up well!
| variantKey, | ||
| context, | ||
| serialId, | ||
| DDEvaluator.featuresWithPrefix( |
There was a problem hiding this comment.
🤖 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.
b747e15 to
bd4c417
Compare
c2e1838
into
danyal.khan/EX-3815-exposure-hook
) * 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.
…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>
Stacked on #12775.
What Does This Do
HOOK:__dd_feature.<key>in the flag metadata, as before.EXPOSURE:__dd_exposure_feature.<key>, whichExposureLoggingHookputs on the exposure event asfeatures.EVALUATION:__dd_evaluation_feature.<key>, whichFlagEvalLoggingHookputs on the flag-evaluation event.featuresfield, with new constructors, toExposureEventandFlagEvalEvent. The existing constructors still work.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
observeFullEvaluationDataconsent, because they come from the flag configuration, not the evaluation context.dd-java-agent, the provider checks for the new constructors and sends events without features.featuresbefore any flag configuration uses these destinations: ddoghq/dd-source#124673.Jira ticket: EX-3828