Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ dependencies {
compileOnly("io.opentelemetry:opentelemetry-api:1.47.0")

testImplementation(project(":products:feature-flagging:feature-flagging-bootstrap"))
// SpanEnrichmentGate resolves FeatureFlaggingConfig at runtime. Without it on the test
// classpath the gate swallows a NoClassDefFoundError and reads as off, so the enrichment
// branch cannot be driven.
testImplementation(project(":products:feature-flagging:feature-flagging-config"))
testImplementation(project(":utils:config-utils"))
testImplementation("io.opentelemetry:opentelemetry-api:1.47.0")
testImplementation(libs.bundles.junit5)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,60 @@
import java.util.Set;
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;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener {

private static final Logger log = LoggerFactory.getLogger(DDEvaluator.class);
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));

static final AtomicBoolean USE_LEGACY_EXPOSURE_API =
new AtomicBoolean(
!(SPLIT_SERIAL_ID_SUPPORTED.get() && exposureSerialIdSupported(ExposureEvent.class)));

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
Expand Down Expand Up @@ -557,7 +602,7 @@ private static <T> ProviderEvaluation<T> resolveVariant(
// present (when enrichment is on) so the span-enrichment hook can decide whether to record the
// subject.
if (SPAN_ENRICHMENT_ENABLED) {
if (split.serialId != null) {
if (SPLIT_SERIAL_ID_SUPPORTED.get() && split.serialId != null) {
metadataBuilder.addInteger(METADATA_SPLIT_SERIAL_ID, split.serialId);
}
metadataBuilder.addBoolean(METADATA_DO_LOG, allocation.doLog != null && allocation.doLog);
Expand All @@ -576,7 +621,7 @@ private static <T> ProviderEvaluation<T> resolveVariant(
.build();
final boolean doLog = allocation.doLog != null && allocation.doLog;
if (doLog) {
dispatchExposure(key, result, context);
dispatchExposure(key, result, context, split);
}
return result;
}
Expand Down Expand Up @@ -649,20 +694,30 @@ private static Double parseDouble(final Object value) {
}

private static <T> void dispatchExposure(
final String flag, final ProviderEvaluation<T> evaluation, final EvaluationContext context) {
final String flag,
final ProviderEvaluation<T> evaluation,
final EvaluationContext context,
final Split split) {
final String allocationKey = allocationKey(evaluation);
final String variantKey = evaluation.getVariant();
if (allocationKey == null || variantKey == null) {
return;
}
final ExposureEvent event =
new ExposureEvent(
System.currentTimeMillis(),
new datadog.trace.api.featureflag.exposure.Allocation(allocationKey),
new datadog.trace.api.featureflag.exposure.Flag(flag),
new datadog.trace.api.featureflag.exposure.Variant(variantKey),
new Subject(context.getTargetingKey(), flattenContext(context)));
final long timestamp = System.currentTimeMillis();
// Exposure types share names with the imported UFC Allocation, Flag, and Variant types.
final datadog.trace.api.featureflag.exposure.Allocation allocation =
new datadog.trace.api.featureflag.exposure.Allocation(allocationKey);
final datadog.trace.api.featureflag.exposure.Flag exposureFlag =
new datadog.trace.api.featureflag.exposure.Flag(flag);
final datadog.trace.api.featureflag.exposure.Variant variant =
new datadog.trace.api.featureflag.exposure.Variant(variantKey);
Comment thread
leoromanovsky marked this conversation as resolved.
final Subject subject = new Subject(context.getTargetingKey(), flattenContext(context));

final ExposureEvent event =
USE_LEGACY_EXPOSURE_API.get()
? new ExposureEvent(timestamp, allocation, exposureFlag, variant, subject)
: new ExposureEvent(
timestamp, allocation, exposureFlag, variant, subject, split.serialId);
FeatureFlaggingGateway.dispatch(event);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package datadog.trace.api.openfeature;

import static java.util.Collections.emptyList;
import static java.util.Collections.emptyMap;
import static java.util.Collections.singletonList;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.mock;

import datadog.trace.api.featureflag.ufc.v1.Allocation;
import datadog.trace.api.featureflag.ufc.v1.Flag;
import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration;
import datadog.trace.api.featureflag.ufc.v1.Split;
import datadog.trace.api.featureflag.ufc.v1.ValueType;
import datadog.trace.api.featureflag.ufc.v1.Variant;
import dev.openfeature.sdk.EvaluationContext;
import dev.openfeature.sdk.MutableContext;
import dev.openfeature.sdk.ProviderEvaluation;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;

/**
* Drives the span-enrichment branch of {@link DDEvaluator}, which the ordinary test task cannot
* reach: the gate is read once into a static final field at class load. The property is set here
* rather than through {@code @WithConfig} because that extension rewrites {@code Config.INSTANCE},
* which this module does not use, and the forked task gives this class its own JVM where nothing
* has loaded the evaluator yet.
*/
class DDEvaluatorSpanEnrichmentForkedTest {

static {
System.setProperty("dd.experimental.flagging.provider.span.enrichment.enabled", "true");
}

@Test
void enrichmentMetadataCarriesTheSplitSerialId() {
final ProviderEvaluation<?> result = evaluate(340132);

assertNotNull(
result.getFlagMetadata().getBoolean(DDEvaluator.METADATA_DO_LOG),
"span enrichment must be on, or the assertions below pass vacuously");
assertEquals(
Integer.valueOf(340132),
result.getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID));
}

/**
* Simulates legacy exposure support while Split.serialId remains available. Falling back to the
* five-argument exposure constructor must not suppress the serial id in enrichment metadata.
*/
@Test
void enrichmentMetadataSurvivesAnAgentWithoutTheExposureConstructor() {
final boolean previous = DDEvaluator.USE_LEGACY_EXPOSURE_API.getAndSet(true);
try {
assertEquals(
Integer.valueOf(340132),
evaluate(340132).getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID));
} finally {
DDEvaluator.USE_LEGACY_EXPOSURE_API.set(previous);
}
}

@Test
void enrichmentMetadataOmitsTheSerialIdWhenTheAgentSplitHasNoField() {
final boolean previous = DDEvaluator.SPLIT_SERIAL_ID_SUPPORTED.getAndSet(false);
try {
assertNull(
evaluate(340132).getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID));
} finally {
DDEvaluator.SPLIT_SERIAL_ID_SUPPORTED.set(previous);
}
}

@Test
void enrichmentMetadataOmitsTheSerialIdWhenTheSplitHasNone() {
assertNull(evaluate(null).getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID));
}

private static ProviderEvaluation<?> evaluate(final Integer serialId) {
final Map<String, Variant> variations = new HashMap<>();
variations.put("on", new Variant("on", 1));
final Split split = new Split(emptyList(), "on", emptyMap(), serialId);
final Allocation allocation =
new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.FALSE);
final Map<String, Flag> flags = new HashMap<>();
flags.put(
"target",
new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation)));

final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class));
evaluator.accept(new ServerConfiguration("", "", true, null, flags));

final EvaluationContext ctx = new MutableContext("target").setTargetingKey("user-1");
return evaluator.evaluate(Integer.class, "target", 23, ctx);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.greaterThan;
import static org.hamcrest.Matchers.hasEntry;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
Expand All @@ -26,6 +28,8 @@
import com.squareup.moshi.Moshi;
import com.squareup.moshi.Types;
import datadog.trace.api.featureflag.FeatureFlaggingGateway;
import datadog.trace.api.featureflag.exposure.ExposureEvent;
import datadog.trace.api.featureflag.exposure.Subject;
import datadog.trace.api.featureflag.ufc.v1.Allocation;
import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration;
import datadog.trace.api.featureflag.ufc.v1.ConditionOperator;
Expand Down Expand Up @@ -394,6 +398,116 @@ public void observeFullEvaluationDataNullConfigFieldTreatedAsFalse() {
equalTo(false));
}

// ---- exposure events carry the split's serial id ----

@Test
public void exposureCarriesTheSplitSerialId() {
assertEquals(Integer.valueOf(340132), exposureFor(340132).serial_id);
}

@Test
public void exposureCarriesSerialIdZero() {
assertEquals(Integer.valueOf(0), exposureFor(0).serial_id);
}

@Test
public void exposureOmitsSerialIdWhenTheSplitHasNone() {
assertNull(exposureFor(null).serial_id);
}

@Test
public void legacyExposureApiDispatchesAnExposureWithoutSerialId() {
final boolean previous = DDEvaluator.USE_LEGACY_EXPOSURE_API.getAndSet(true);
Comment thread
danyal002 marked this conversation as resolved.
try {
assertNull(exposureFor(7).serial_id);
} finally {
DDEvaluator.USE_LEGACY_EXPOSURE_API.set(previous);
}
}

// ---- old-agent bootstrap probe ----

/** A Split from an agent that predates the serial id: the field does not exist. */
static final class SplitWithoutSerialId {}

/** A Split whose serialId is not the Integer the dispatch site reads. */
static final class SplitWithWrongSerialIdType {
public long serialId;
}

/** An ExposureEvent from an agent that predates the serial id: only the five-arg constructor. */
static final class LegacyExposureEvent {
// Qualify exposure types that share names with the imported UFC types.
LegacyExposureEvent(
final long timestamp,
final datadog.trace.api.featureflag.exposure.Allocation allocation,
final datadog.trace.api.featureflag.exposure.Flag flag,
final datadog.trace.api.featureflag.exposure.Variant variant,
final Subject subject) {}
}

/**
* Positive control. The probe must agree with the bootstrap actually on the classpath, or the
* negative cases below would pass for the wrong reason and the feature would ship switched off.
*/
@Test
public void probeAcceptsTheBootstrapOnTheClasspath() {
assertTrue(DDEvaluator.splitSerialIdSupported(Split.class));
assertTrue(DDEvaluator.exposureSerialIdSupported(ExposureEvent.class));
assertTrue(DDEvaluator.SPLIT_SERIAL_ID_SUPPORTED.get());
assertFalse(DDEvaluator.USE_LEGACY_EXPOSURE_API.get());
}

@Test
public void probeRejectsAnAgentWhoseSplitHasNoSerialId() {
assertFalse(DDEvaluator.splitSerialIdSupported(SplitWithoutSerialId.class));
}

@Test
public void probeRejectsAnAgentWhoseSerialIdIsNotAnInteger() {
assertFalse(DDEvaluator.splitSerialIdSupported(SplitWithWrongSerialIdType.class));
}

@Test
public void probeRejectsAnAgentWithoutTheSerialIdConstructor() {
assertFalse(DDEvaluator.exposureSerialIdSupported(LegacyExposureEvent.class));
}

/**
* Tests legacy bootstrap behavior when Split.serialId exists but the exposure event has only the
* five-argument constructor. Split support must remain independent of exposure constructor
* support.
*/
@Test
public void probeKeepsSplitSupportWhenOnlyTheEventConstructorIsMissing() {
assertTrue(DDEvaluator.splitSerialIdSupported(Split.class));
assertFalse(DDEvaluator.exposureSerialIdSupported(LegacyExposureEvent.class));
}
Comment thread
leoromanovsky marked this conversation as resolved.

/**
* Evaluates a logging allocation whose split carries the given serial id and returns the single
* dispatched exposure. Span enrichment is off here, as it is by default, so this also pins that
* the serial id does not travel via the enrichment-gated evaluation metadata.
*/
private static ExposureEvent exposureFor(final Integer serialId) {
final List<ExposureEvent> dispatched = new ArrayList<>();
final FeatureFlaggingGateway.ExposureListener listener = dispatched::add;
FeatureFlaggingGateway.addExposureListener(listener);
try {
final Map<String, Variant> variations = new HashMap<>();
variations.put("on", new Variant("on", 1));
final Split split = new Split(emptyList(), "on", emptyMap(), serialId);
final Allocation allocation =
new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.TRUE);
evaluateFlag(
new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation)), true);
} finally {
FeatureFlaggingGateway.removeExposureListener(listener);
}
assertEquals(1, dispatched.size());
return dispatched.get(0);
}

// Builds a flag that reaches resolveVariant: enabled, one allocation with no rules, one split
// with empty shards (so the shard-match branch is skipped and the split is picked immediately),
// and a single "on" variant whose value maps to the requested Integer type.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,29 @@ public class ExposureEvent {
public final Variant variant;
public final Subject subject;

public final Integer serial_id;

public ExposureEvent(
final long timestamp,
final Allocation allocation,
final Flag flag,
final Variant variant,
final Subject subject) {
this(timestamp, allocation, flag, variant, subject, null);
}

public ExposureEvent(
final long timestamp,
final Allocation allocation,
final Flag flag,
final Variant variant,
final Subject subject,
final Integer serialId) {
Comment thread
danyal002 marked this conversation as resolved.
this.timestamp = timestamp;
this.allocation = allocation;
this.flag = flag;
this.variant = variant;
this.subject = subject;
this.serial_id = serialId;
}
}
Loading
Loading