diff --git a/dd-openfeature/build.gradle.kts b/dd-openfeature/build.gradle.kts index f583a0cc81d..c047c567c13 100644 --- a/dd-openfeature/build.gradle.kts +++ b/dd-openfeature/build.gradle.kts @@ -29,16 +29,17 @@ java { dependencies { api("dev.openfeature:sdk:1.22.0") + api("io.opentelemetry:opentelemetry-api:1.66.0") api(libs.jackson.core) testImplementation(libs.bundles.junit5) testImplementation(libs.bundles.mockito) } -tasks.withType().configureEach { - configureCompiler(JavaVersion.VERSION_11) -} - jmh { jmhVersion = libs.versions.jmh.get() } + +tasks.withType().configureEach { + configureCompiler(JavaVersion.VERSION_11) +} diff --git a/dd-openfeature/gradle.lockfile b/dd-openfeature/gradle.lockfile index 8fa9448ffd5..07ad4ce31c3 100644 --- a/dd-openfeature/gradle.lockfile +++ b/dd-openfeature/gradle.lockfile @@ -18,6 +18,9 @@ commons-io:commons-io:2.21.0=spotbugs de.thetaphi:forbiddenapis:3.11=compileClasspath,jmhCompileClasspath dev.openfeature:sdk:1.22.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.leangen.geantyref:geantyref:1.3.16=jmhRuntimeClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-api:1.66.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-common:1.66.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-context:1.66.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy-agent:1.18.12=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.18.12=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/dd-openfeature/src/jmh/java/com/datadog/openfeature/FlagEvalHookHotPathBenchmark.java b/dd-openfeature/src/jmh/java/com/datadog/openfeature/FlagEvalHookHotPathBenchmark.java new file mode 100644 index 00000000000..34412974120 --- /dev/null +++ b/dd-openfeature/src/jmh/java/com/datadog/openfeature/FlagEvalHookHotPathBenchmark.java @@ -0,0 +1,188 @@ +package com.datadog.openfeature; + +import static java.util.concurrent.TimeUnit.NANOSECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; + +import com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.FlagValueType; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.ImmutableStructure; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Evaluation-thread benchmark for the OpenFeature hook: the inline cost a caller actually pays + * inside FlagEvalLoggingHook.finallyAfter, across context shapes. + * + *

This is the counterpart to FlagEvaluationHotPathBenchmark, which measures the writer queue and + * the worker-thread aggregation. + * + *

The dominant inline cost under consent-on is DDEvaluator.copyPrunedContext: one bounded walk + * of the caller-owned EvaluationContext. contextCopy isolates that walk so its share of + * hookFinallyAfter is directly readable. Under consent-off the hook skips the copy entirely, which + * hookFinallyAfterConsentOff measures as the protected-path floor. + * + *

The writer used here is a no-op that discards events, so no queue or worker cost is included. + * + *

Run: {@code ./gradlew :dd-openfeature:jmh -Pjmh.includes=FlagEvalHookHotPathBenchmark}. + */ +@State(Scope.Benchmark) +@Warmup(iterations = 3, time = 2, timeUnit = SECONDS) +@Measurement(iterations = 5, time = 1, timeUnit = SECONDS) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(NANOSECONDS) +@Fork(value = 1) +public class FlagEvalHookHotPathBenchmark { + + /** + * Context shapes. The three 100-leaf shapes (flat/100attrs, nested/10structs_10fields, + * list/10lists_10items) carry the same leaf count under different structure, so the spread + * between them isolates shape cost from leaf count. + */ + @Param({ + "flat/0attrs", + "flat/10attrs", + "flat/100attrs", + "nested/10structs_10fields", + "list/10lists_10items" + }) + public String shape; + + private HookContext hookContext; + private FlagEvaluationDetails consentOnDetails; + private FlagEvaluationDetails consentOffDetails; + private FlagEvalLoggingHook hook; + + @Setup(Level.Trial) + public void setUp() { + final MutableContext ctx = buildContext(shape); + hookContext = + HookContext.builder() + .flagKey("bench-flag") + .type(FlagValueType.STRING) + .defaultValue("default") + .ctx(ctx) + .build(); + + consentOnDetails = details(true); + consentOffDetails = details(false); + + // Discarding writer: isolates hook-inline cost from queue mechanics. + final FlagEvaluationWriter writer = new NoOpWriter(); + hook = new FlagEvalLoggingHook<>(() -> writer); + } + + /** Total inline cost under consent-on: scalar extraction, bounded context copy, and enqueue. */ + @Benchmark + public void hookFinallyAfter() { + hook.finallyAfter(hookContext, consentOnDetails, Collections.emptyMap()); + } + + /** Protected-path floor: consent-off skips the context copy, leaving scalar work plus enqueue. */ + @Benchmark + public void hookFinallyAfterConsentOff() { + hook.finallyAfter(hookContext, consentOffDetails, Collections.emptyMap()); + } + + /** The bounded context copy alone - the component that scales with context shape. */ + @Benchmark + public void contextCopy(final Blackhole blackhole) { + blackhole.consume(DDEvaluator.copyPrunedContext(hookContext.getCtx())); + } + + private static FlagEvaluationDetails details(final boolean observeFullEvaluationData) { + return FlagEvaluationDetails.builder() + .flagKey("bench-flag") + .value("on-value") + .variant("on") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata( + ImmutableMetadata.builder() + .addString("allocationKey", "alloc-1") + .addLong("__dd_eval_timestamp_ms", 1_700_000_000_000L) + .addBoolean( + DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) + .build()) + .build(); + } + + private static MutableContext buildContext(final String shape) { + final MutableContext ctx = new MutableContext("bench-user"); + if ("flat/0attrs".equals(shape)) { + return ctx; + } + if ("flat/10attrs".equals(shape)) { + return addFlat(ctx, 10); + } + if ("flat/100attrs".equals(shape)) { + return addFlat(ctx, 100); + } + if ("nested/10structs_10fields".equals(shape)) { + for (int i = 0; i < 10; i++) { + final Map inner = new HashMap<>(); + for (int j = 0; j < 10; j++) { + inner.put("field" + j, new Value("value" + j)); + } + ctx.add("struct" + i, new ImmutableStructure(inner)); + } + return ctx; + } + if ("list/10lists_10items".equals(shape)) { + for (int i = 0; i < 10; i++) { + final List items = new ArrayList<>(10); + for (int j = 0; j < 10; j++) { + items.add(new Value("value" + j)); + } + ctx.add("list" + i, items); + } + return ctx; + } + throw new IllegalArgumentException("unknown benchmark shape: " + shape); + } + + private static MutableContext addFlat(final MutableContext ctx, final int count) { + for (int i = 0; i < count; i++) { + ctx.add("field" + i, "value" + i); + } + return ctx; + } + + /** Discards events so only hook-inline work is measured. */ + private static final class NoOpWriter implements FlagEvaluationWriter { + @Override + public void enqueue(final FlagEvalEvent event) {} + + @Override + public boolean hasCapacityForEnqueue() { + return true; + } + + @Override + public void countPreQueueOverflow() {} + + @Override + public void countContextTruncated(final String reason) {} + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java new file mode 100644 index 00000000000..f1ae8d1b40a --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java @@ -0,0 +1,515 @@ +package com.datadog.openfeature; + +import static java.util.Arrays.asList; + +import com.datadog.openfeature.internal.FeatureFlagsRuntime; +import com.datadog.openfeature.internal.config.Settings; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.evaluation.ConfigurationStore; +import com.datadog.openfeature.internal.evaluation.FlagEvaluator; +import com.datadog.openfeature.internal.exposure.ExposureEvent; +import com.datadog.openfeature.internal.exposure.Subject; +import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.ValueType; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.ImmutableStructure; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Structure; +import dev.openfeature.sdk.Value; +import java.time.Instant; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +class DDEvaluator implements Evaluator, Consumer { + + private static final Set> SUPPORTED_RESOLUTION_TYPES = + new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Value.class)); + + /** + * 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 + * list/structure would overflow that thread's stack - and a StackOverflowError is not caught by + * the LinkageError | Exception guards that keep telemetry from breaking an evaluation. Values + * below the limit are truncated to null, the same way the cycle guard truncates. Kept aligned + * with the cross-SDK RFC target (4). + */ + static final int MAX_SNAPSHOT_DEPTH = 4; + + /** + * Maximum number of top-level context fields retained by copyPrunedContext. Bounds the width of + * the caller-supplied context and, transitively, the size of every FlagEvalEvent sitting in the + * async hand-off queue. Kept aligned with the cross-SDK RFC. + */ + static final int MAX_CONTEXT_FIELDS = 256; + + /** + * Maximum character length for a single context KEY retained by copyPrunedContext. Keys are + * stored verbatim in every full-tier bucket, so an unbounded key size would let a single caller + * inflate steady-state heap use. Longer keys cause the field to be skipped. + */ + static final int MAX_KEY_LENGTH = 256; + + /** + * Maximum character length for a single context string VALUE retained by copyPrunedContext. + * Longer values cause the field to be skipped (matches previous pruneContext behavior). + * Non-string scalars are not length-bounded. + */ + static final int MAX_VALUE_LENGTH = 256; + + /** + * Maximum number of elements walked per list encountered during copyPrunedContext. Bounds the + * fan-out of a single wide list at capture time so one caller cannot inflate the hot path with a + * huge but shallow structure. Elements past the limit are skipped. + */ + static final int MAX_LIST_ELEMENTS = 256; + + /** + * Maximum number of properties walked per structure encountered during copyPrunedContext. Same + * intent as MAX_LIST_ELEMENTS for structures. Properties past the limit are skipped. + */ + static final int MAX_STRUCTURE_PROPERTIES = 256; + + // Evaluation-metadata keys consumed by the span-enrichment capture hook (see + // SpanEnrichmentHook). Emitted only when the span-enrichment gate is on. + static final String METADATA_SPLIT_SERIAL_ID = "__dd_split_serial_id"; + static final String METADATA_DO_LOG = "__dd_do_log"; + + // Stamped on every DD-produced evaluation (including PROVIDER_NOT_READY, with false). Missing + // key = non-DD provider; the hook falls back to false (fail-closed). + static final String METADATA_OBSERVE_FULL_EVALUATION_DATA = "observe_full_evaluation_data"; + + private final Runnable configCallback; + private final Connector connector; + private final Settings settings; + private final FlagEvaluator core; + private final ConfigurationStore configuration = new ConfigurationStore(); + private volatile FeatureFlagsRuntime runtime; + + DDEvaluator(final Runnable configCallback, final Connector connector, final Settings settings) { + this.configCallback = configCallback; + this.connector = connector; + this.settings = settings; + // When span enrichment is off, the __dd_* span-enrichment metadata is not attached to + // evaluations, so an enabled provider pays nothing extra. + this.core = new FlagEvaluator(settings.isSpanEnrichmentEnabled(), this::dispatchExposure); + } + + @Override + public synchronized boolean initialize( + final long timeout, final TimeUnit unit, final EvaluationContext context) throws Exception { + if (this.runtime != null) { + return hasConfiguration(); + } + final FeatureFlagsRuntime acquired = FeatureFlagsRuntime.acquire(this.connector, this.settings); + this.runtime = acquired; + acquired.addConfigurationListener(this); + return this.configuration.await(timeout, unit) || hasConfiguration(); + } + + @Override + public boolean hasConfiguration() { + return this.configuration.get() != null; + } + + @Override + public synchronized void shutdown() { + final FeatureFlagsRuntime released = this.runtime; + this.runtime = null; + if (released != null) { + released.removeConfigurationListener(this); + FeatureFlagsRuntime.release(); + } + } + + /** + * @return the runtime, or {@code null} if the evaluator is not initialized. + */ + FeatureFlagsRuntime runtime() { + return this.runtime; + } + + @Override + public void accept(final ServerConfiguration config) { + this.configuration.set(config); + if (config != null) { + this.configuration.markReady(); + this.configCallback.run(); + } else if (this.configuration.wasReady()) { + this.configCallback.run(); + } + } + + @Override + public ProviderEvaluation evaluate( + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context) { + return core.evaluate(configuration.get(), target, key, defaultValue, context); + } + + static boolean isAllocationActive(final Allocation allocation, final Instant now) { + return FlagEvaluator.isAllocationActive(allocation, now); + } + + static boolean isTypeCompatible(final Class target, final ValueType type) { + return FlagEvaluator.isTypeCompatible(target, type); + } + + static T mapValue(final Class target, final Object value) { + return FlagEvaluator.mapValue(target, value); + } + + private void dispatchExposure( + final String flag, + final ProviderEvaluation evaluation, + final Integer serialId, + final EvaluationContext context) { + final String allocationKey = allocationKey(evaluation); + final String variantKey = evaluation.getVariant(); + if (allocationKey == null || variantKey == null) { + return; + } + final ExposureEvent event = + new ExposureEvent( + System.currentTimeMillis(), + new com.datadog.openfeature.internal.exposure.Allocation(allocationKey), + new com.datadog.openfeature.internal.exposure.Flag(flag), + new com.datadog.openfeature.internal.exposure.Variant(variantKey), + new Subject(context.getTargetingKey(), flattenContext(context)), + serialId); + recordExposure(event); + } + + /** + * Records an exposure to the runtime, if initialized. + * + * @param event the exposure to record. + */ + void recordExposure(final ExposureEvent event) { + final FeatureFlagsRuntime current = this.runtime; + if (current != null) { + current.recordExposure(event); + } + } + + private static String allocationKey(final ProviderEvaluation resolution) { + final ImmutableMetadata meta = resolution.getFlagMetadata(); + return meta == null ? null : meta.getString("allocationKey"); + } + + static AbstractMap flattenContext(final EvaluationContext context) { + return flattenValues(snapshotValues(context)); + } + + static Map snapshotValues(final EvaluationContext context) { + final HashMap values = new HashMap<>(); + final Set seenContainers = Collections.newSetFromMap(new IdentityHashMap<>()); + for (final String key : context.keySet()) { + values.put(key, snapshotValue(context.getValue(key), seenContainers, 0)); + } + return values; + } + + private static Value snapshotValue( + final Value value, final Set seenContainers, final int depth) { + if (value == null) { + return null; + } else if (value.isNull()) { + return new Value(); + } else if (value.isBoolean()) { + return new Value(value.asBoolean()); + } else if (value.isNumber()) { + final Object number = value.asObject(); + return number instanceof Integer + ? new Value((Integer) number) + : new Value(((Number) number).doubleValue()); + } else if (value.isString()) { + return new Value(value.asString()); + } else if (value.isInstant()) { + return new Value(value.asInstant()); + } else if (value.isList()) { + final List list = value.asList(); + if (depth >= MAX_SNAPSHOT_DEPTH || !seenContainers.add(list)) { + return new Value(); + } + final List snapshot = new ArrayList<>(list.size()); + for (final Value item : list) { + snapshot.add(snapshotValue(item, seenContainers, depth + 1)); + } + seenContainers.remove(list); + return new Value(Collections.unmodifiableList(snapshot)); + } else if (value.isStructure()) { + final Structure structure = value.asStructure(); + if (depth >= MAX_SNAPSHOT_DEPTH || !seenContainers.add(structure)) { + return new Value(); + } + final Map snapshot = new HashMap<>(); + for (final String key : structure.keySet()) { + snapshot.put(key, snapshotValue(structure.getValue(key), seenContainers, depth + 1)); + } + seenContainers.remove(structure); + return new Value(new ImmutableStructure(snapshot)); + } + throw new IllegalArgumentException("Unsupported OpenFeature value type: " + value); + } + + static AbstractMap flattenValues(final Map values) { + final HashMap result = new HashMap<>(); + final Set seenContainers = Collections.newSetFromMap(new IdentityHashMap<>()); + for (final Map.Entry root : values.entrySet()) { + final Deque deque = new LinkedList<>(); + deque.push(new FlattenEntry(root.getKey(), root.getValue())); + while (!deque.isEmpty()) { + final FlattenEntry entry = deque.pop(); + final Value value = entry.value; + if (value == null) { + result.put(entry.key, null); + } else if (value.isList()) { + final List list = value.asList(); + if (seenContainers.add(list)) { + for (int i = 0; i < list.size(); i++) { + deque.push(new FlattenEntry(entry.key + "[" + i + "]", list.get(i))); + } + } + } else if (value.isStructure()) { + final Structure structure = value.asStructure(); + if (seenContainers.add(structure)) { + for (final String property : structure.keySet()) { + deque.push( + new FlattenEntry(entry.key + "." + property, structure.getValue(property))); + } + } + } else { + result.put(entry.key, convertValue(value)); + } + } + } + return result; + } + + private static Object convertValue(final Value value) { + if (value == null || value.isNull()) { + return null; + } else if (value.isBoolean()) { + return value.asBoolean(); + } else if (value.isNumber()) { + return value.asObject(); + } else if (value.isString()) { + return value.asString(); + } else if (value.isInstant()) { + return value.asInstant().toString(); + } + throw new IllegalArgumentException("Unsupported OpenFeature value type: " + value); + } + + // Reason-code bitmask constants used by copyPrunedContext to track which caps fired. + static final int REASON_MAX_CONTEXT_FIELDS = 1; + static final int REASON_MAX_KEY_LENGTH = 1 << 1; + static final int REASON_MAX_VALUE_LENGTH = 1 << 2; + static final int REASON_MAX_LIST_ELEMENTS = 1 << 3; + static final int REASON_MAX_STRUCTURE_PROPERTIES = 1 << 4; + static final int REASON_MAX_SNAPSHOT_DEPTH = 1 << 5; + static final int REASON_CYCLE = 1 << 6; + + /** Sorted reason-code strings, indexed by their bit position in the bitmask. */ + private static final String[] REASON_NAMES = { + "max_context_fields", + "max_key_length", + "max_value_length", + "max_list_elements", + "max_structure_properties", + "max_snapshot_depth", + "cycle", + }; + + /** + * Builds the sorted, comma-separated reason tag string from a bitmask of fired reason codes. + * Returns null when no reason bit is set (no truncation occurred). The returned string is ready + * to use directly as the "reason:..." tag value. + */ + static String truncationReasonTag(final int reasonMask) { + if (reasonMask == 0) { + return null; + } + final StringBuilder sb = new StringBuilder(); + for (int bit = 0; bit < REASON_NAMES.length; bit++) { + if ((reasonMask & (1 << bit)) != 0) { + if (sb.length() > 0) { + sb.append(','); + } + sb.append(REASON_NAMES[bit]); + } + } + return sb.toString(); + } + + /** + * Result of copyPrunedContext: the pruned attribute map plus an optional reason tag describing + * which caps fired during the walk. truncatedReason is null when no truncation occurred, so + * callers can skip the telemetry path with a single null check. + */ + static final class CopyResult { + final Map attrs; + + /** Non-null when at least one cap fired; ready to use as the "reason:..." tag value. */ + final String truncatedReason; + + CopyResult(final Map attrs, final String truncatedReason) { + this.attrs = attrs; + this.truncatedReason = truncatedReason; + } + } + + /** + * Single-pass bounded copy of a caller-owned EvaluationContext into the flattened, pruned map + * stored on a FlagEvalEvent and later canonicalized by the aggregator. + * + *

Every retained-size dimension is capped inline so the hot path performs work proportional to + * what is kept, never to what the caller supplied: MAX_CONTEXT_FIELDS - stop iterating the + * top-level context past this many retained fields MAX_KEY_LENGTH - skip fields whose flattened + * key exceeds this length (also enforced on every path segment produced by descending into + * lists/structures) MAX_VALUE_LENGTH - skip string values exceeding this length MAX_LIST_ELEMENTS + * - stop iterating a list past this many elements MAX_STRUCTURE_PROPERTIES - stop iterating a + * structure past this many properties MAX_SNAPSHOT_DEPTH - stop descending into lists/structures + * past this depth Cycle guard - identity-tracked containers currently on the recursion stack are + * treated as leaves + * + *

All numeric limits are named constants so they can be tuned independently. + * + *

Returns a CopyResult whose attrs is an empty map for null/empty input and whose + * truncatedReason is non-null when at least one cap fired. The returned map is a plain HashMap; + * canonical-key sorting happens once in the aggregator, off the hot path. + */ + static CopyResult copyPrunedContext(final EvaluationContext context) { + if (context == null) { + return new CopyResult(Collections.emptyMap(), null); + } + final Set keys = context.keySet(); + if (keys.isEmpty()) { + return new CopyResult(Collections.emptyMap(), null); + } + final HashMap out = new HashMap<>(); + final Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + final int[] reasonMask = {0}; + for (final String key : keys) { + if (out.size() >= MAX_CONTEXT_FIELDS) { + reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; + break; + } + if (EvaluationContext.TARGETING_KEY.equals(key)) { + continue; + } + copyPrunedValue(out, key, context.getValue(key), seen, 0, reasonMask); + } + final Map attrs = out.isEmpty() ? Collections.emptyMap() : out; + return new CopyResult(attrs, truncationReasonTag(reasonMask[0])); + } + + private static void copyPrunedValue( + final Map out, + final String key, + final Value value, + final Set seen, + final int depth, + final int[] reasonMask) { + if (out.size() >= MAX_CONTEXT_FIELDS) { + reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; + return; + } + if (key.length() > MAX_KEY_LENGTH) { + reasonMask[0] |= REASON_MAX_KEY_LENGTH; + return; + } + if (value == null || value.isNull()) { + out.put(key, null); + return; + } + if (value.isString()) { + final String s = value.asString(); + if (s.length() > MAX_VALUE_LENGTH) { + reasonMask[0] |= REASON_MAX_VALUE_LENGTH; + return; + } + out.put(key, s); + return; + } + if (value.isBoolean() || value.isNumber() || value.isInstant()) { + out.put(key, convertValue(value)); + return; + } + if (value.isList()) { + final List list = value.asList(); + if (depth >= MAX_SNAPSHOT_DEPTH) { + reasonMask[0] |= REASON_MAX_SNAPSHOT_DEPTH; + return; + } + if (!seen.add(list)) { + reasonMask[0] |= REASON_CYCLE; + return; + } + if (list.size() > MAX_LIST_ELEMENTS) { + reasonMask[0] |= REASON_MAX_LIST_ELEMENTS; + } + final int limit = Math.min(list.size(), MAX_LIST_ELEMENTS); + for (int i = 0; i < limit; i++) { + if (out.size() >= MAX_CONTEXT_FIELDS) { + reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; + break; + } + copyPrunedValue(out, key + "[" + i + "]", list.get(i), seen, depth + 1, reasonMask); + } + seen.remove(list); + return; + } + if (value.isStructure()) { + final Structure structure = value.asStructure(); + if (depth >= MAX_SNAPSHOT_DEPTH) { + reasonMask[0] |= REASON_MAX_SNAPSHOT_DEPTH; + return; + } + if (!seen.add(structure)) { + reasonMask[0] |= REASON_CYCLE; + return; + } + int walked = 0; + for (final String property : structure.keySet()) { + if (walked >= MAX_STRUCTURE_PROPERTIES) { + reasonMask[0] |= REASON_MAX_STRUCTURE_PROPERTIES; + break; + } + if (out.size() >= MAX_CONTEXT_FIELDS) { + reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; + break; + } + walked++; + copyPrunedValue( + out, key + "." + property, structure.getValue(property), seen, depth + 1, reasonMask); + } + seen.remove(structure); + } + } + + private static class FlattenEntry { + private final String key; + private final Value value; + + private FlattenEntry(final String key, final Value value) { + this.key = key; + this.value = value; + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/Evaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/Evaluator.java new file mode 100644 index 00000000000..10176fe09a2 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/Evaluator.java @@ -0,0 +1,17 @@ +package com.datadog.openfeature; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ProviderEvaluation; +import java.util.concurrent.TimeUnit; + +interface Evaluator { + + boolean initialize(long timeout, TimeUnit timeUnit, EvaluationContext context) throws Exception; + + boolean hasConfiguration(); + + void shutdown(); + + ProviderEvaluation evaluate( + Class target, String key, T defaultValue, EvaluationContext context); +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java new file mode 100644 index 00000000000..5baab2df44a --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java @@ -0,0 +1,143 @@ +package com.datadog.openfeature; + +import com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableMetadata; +import java.util.Collections; +import java.util.Map; +import java.util.function.Supplier; + +/** + * OpenFeature Hook that captures flag evaluation events for EVP flagevaluation emission. + * + *

Contract: finallyAfter does scalar metadata extraction, a single bounded copy of the + * evaluation context, and a non-blocking offer to the writer's bounded queue. Aggregation and + * posting are deferred to the writer's worker thread. + * + *

Hot-path cost: under consent-on (observeFullEvaluationData=true) DDEvaluator.copyPrunedContext + * performs one bounded walk of the caller-owned EvaluationContext, applying every retained-size cap + * inline so work is proportional to what is kept, never to what the caller supplied. The returned + * map is capped by field count, key length, value length, list width, structure width, and depth. + * Under consent-off the context is dropped on emit, so the copy is skipped entirely and the hot + * path is scalar-only. + * + *

This hook is registered alongside the existing OTel FlagEvalMetricsHook - it does NOT replace + * it. + * + *

The pipeline is resolved lazily on each call, so the hook is always safe to register - if the + * pipeline is absent (killswitch off or not yet started) it is a no-op. + */ +class FlagEvalLoggingHook implements Hook { + + /** Pipeline resolver, returning {@code null} when evaluation counts are not delivered. */ + private final Supplier writerSupplier; + + FlagEvalLoggingHook(final Supplier writerSupplier) { + this.writerSupplier = writerSupplier; + } + + /** + * Capture + non-blocking enqueue only; no flattening, aggregation, or I/O. Runs at the finally + * stage so it covers success, error, and default-value paths. + * + *

The context snapshot taken here scales with context size/nesting - see the class javadoc for + * why it cannot be deferred. + */ + @Override + public void finallyAfter( + final HookContext ctx, + final FlagEvaluationDetails details, + final Map hints) { + try { + if (details == null) { + return; + } + final FlagEvaluationWriter w = writerSupplier.get(); + if (w == null) { + return; + } + + // Pre-queue guard: if the queue is already saturated, avoid the context-copy work whose + // result would be discarded on offer(). Best-effort — the queue can still be full at + // offer() time — but flips a full snapshot into an O(1) check on the drop path. + if (!w.hasCapacityForEnqueue()) { + w.countPreQueueOverflow(); + return; + } + + // Scalar extraction - individual typed metadata reads, no JSON, no flattening + final String flagKey = details.getFlagKey(); + final ImmutableMetadata metadata = details.getFlagMetadata(); + + // allocationKey: "allocationKey" (camelCase) - consistent with FlagEvalMetricsHook.java + final String allocationKey = metadata != null ? metadata.getString("allocationKey") : null; + + // eval-time: from flag metadata "__dd_eval_timestamp_ms" (Long), fallback to hook-fire time. + // ImmutableMetadata.getLong available since sdk 1.4+. + final Long evalTimeObj = metadata != null ? metadata.getLong("__dd_eval_timestamp_ms") : null; + final long evalTimeMs = evalTimeObj != null ? evalTimeObj : System.currentTimeMillis(); + + // variant: the OpenFeature variant key (same source as the OTel FlagEvalMetricsHook), NOT the + // evaluated value. A null variant means no variant was selected (runtime default). + final String variant = details.getVariant(); + + // targetingKey from evaluation context + final String targetingKey = + ctx != null && ctx.getCtx() != null ? ctx.getCtx().getTargetingKey() : null; + + // Consent is read from metadata stamped by DDEvaluator (pinned to its ServerConfiguration). + // Missing key = non-DD provider → false, the privacy-preserving default. + final Boolean consentFromMetadata = + metadata != null + ? metadata.getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA) + : null; + final boolean observeFullEvaluationData = consentFromMetadata != null && consentFromMetadata; + + // Error message: prefer the human-readable message under consent-on; under consent-off the + // provider's raw message can echo evaluation-context values (e.g. NumberFormatException: + // "For input string: \"jane.doe@...\""), so replace it with the ErrorCode name — a stable, + // PII-free signal. Same substitution path is used when the message is absent regardless of + // consent (some providers populate only the code). Null on success. + String errorMessage = observeFullEvaluationData ? details.getErrorMessage() : null; + if ((errorMessage == null || errorMessage.isEmpty()) && details.getErrorCode() != null) { + errorMessage = details.getErrorCode().name(); + } + if (errorMessage != null && errorMessage.isEmpty()) { + errorMessage = null; + } + + // On the protected path (consent-off) the evaluation context is dropped on emit and never + // consulted by the aggregator, so skip the bounded copy entirely — the copy cost only + // applies under consent-on. + final Map attrs; + if (observeFullEvaluationData && ctx != null && ctx.getCtx() != null) { + // Bounded copy of the caller's mutable context (see DDEvaluator.copyPrunedContext for + // every retained-size cap). Runs inline because the event is consumed asynchronously + // and the source context is caller-owned; work is proportional to what is retained. + final DDEvaluator.CopyResult copy = DDEvaluator.copyPrunedContext(ctx.getCtx()); + if (copy.truncatedReason != null) { + w.countContextTruncated(copy.truncatedReason); + } + attrs = copy.attrs; + } else { + attrs = Collections.emptyMap(); + } + + w.enqueue( + new FlagEvalEvent( + flagKey, + variant, + allocationKey, + targetingKey, + errorMessage, + evalTimeMs, + observeFullEvaluationData, + attrs)); + } catch (LinkageError e) { + // Never let EVP recording break flag evaluation + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetrics.java b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetrics.java new file mode 100644 index 00000000000..a108e0fd582 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetrics.java @@ -0,0 +1,161 @@ +package com.datadog.openfeature; + +import dev.openfeature.sdk.ErrorCode; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.Meter; +import java.io.Closeable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class FlagEvalMetrics implements Closeable { + + private static final Logger log = LoggerFactory.getLogger(FlagEvalMetrics.class); + + private static final String METER_NAME = "ddtrace.openfeature"; + private static final String METRIC_NAME = "feature_flag.evaluations"; + private static final String METRIC_UNIT = "{evaluation}"; + private static final String METRIC_DESC = "Number of feature flag evaluations"; + + private static final AttributeKey ATTR_FLAG_KEY = + AttributeKey.stringKey("feature_flag.key"); + private static final AttributeKey ATTR_VARIANT = + AttributeKey.stringKey("feature_flag.result.variant"); + private static final AttributeKey ATTR_REASON = + AttributeKey.stringKey("feature_flag.result.reason"); + private static final AttributeKey ATTR_ERROR_TYPE = AttributeKey.stringKey("error.type"); + private static final AttributeKey ATTR_ALLOCATION_KEY = + AttributeKey.stringKey("feature_flag.result.allocation_key"); + + private volatile LongCounter counter; + private volatile OpenTelemetry telemetry; + private volatile boolean closed; + private volatile Boolean legacyAgentBridge; + private volatile boolean getOrNoopUnavailable; + private final boolean suppliedCounter; + + FlagEvalMetrics() { + suppliedCounter = false; + } + + private LongCounter resolveCounter() { + if (closed || suppliedCounter) { + return counter; + } + try { + final OpenTelemetry current = currentOpenTelemetry(); + if (current == telemetry) { + return counter; + } + return initializeCounter(current); + } catch (LinkageError e) { + log.debug("OpenTelemetry API is not available for evaluation metrics", e); + shutdown(); + } catch (Exception e) { + log.error("Failed to initialize flag evaluation metrics", e); + } + return null; + } + + private OpenTelemetry currentOpenTelemetry() { + if (!getOrNoopUnavailable) { + try { + OpenTelemetry current = GlobalOpenTelemetry.getOrNoop(); + if (current == OpenTelemetry.noop() && hasLegacyAgentBridge()) { + // Older OTel agents bridge get(), but predate getOrNoop(). Only use get() when the + // agent has injected its API bridge. Mere agent-JAR presence is not sufficient. + current = GlobalOpenTelemetry.get(); + } + return current; + } catch (NoSuchMethodError e) { + // getOrNoop() was added in OpenTelemetry API 1.57 + getOrNoopUnavailable = true; + } + } + return GlobalOpenTelemetry.get(); + } + + private boolean hasLegacyAgentBridge() { + Boolean available = legacyAgentBridge; + if (available == null) { + try { + Class.forName( + "io.opentelemetry.javaagent.instrumentation.opentelemetryapi.ApplicationOpenTelemetry", + false, + GlobalOpenTelemetry.class.getClassLoader()); + available = true; + } catch (ClassNotFoundException | LinkageError | SecurityException ignored) { + available = false; + } + legacyAgentBridge = available; + } + return available; + } + + private synchronized LongCounter initializeCounter(final OpenTelemetry current) { + if (closed) { + return null; + } + if (current != telemetry) { + Meter meter = current.getMeterProvider().meterBuilder(METER_NAME).build(); + counter = + meter + .counterBuilder(METRIC_NAME) + .setUnit(METRIC_UNIT) + .setDescription(METRIC_DESC) + .build(); + telemetry = current; + + log.debug("Flag evaluation metrics initialized"); + } + return counter; + } + + /** Package-private constructor for testing with a mock counter. */ + FlagEvalMetrics(LongCounter counter) { + this.suppliedCounter = true; + this.counter = counter; + } + + void record( + String flagKey, String variant, String reason, ErrorCode errorCode, String allocationKey) { + LongCounter c = resolveCounter(); + if (c == null) { + return; + } + try { + AttributesBuilder builder = + Attributes.builder() + .put(ATTR_FLAG_KEY, flagKey) + .put(ATTR_VARIANT, variant != null ? variant : "") + .put(ATTR_REASON, reason != null ? reason.toLowerCase() : "unknown"); + + if (errorCode != null) { + builder.put(ATTR_ERROR_TYPE, errorCode.name().toLowerCase()); + } + + if (allocationKey != null && !allocationKey.isEmpty()) { + builder.put(ATTR_ALLOCATION_KEY, allocationKey); + } + + c.add(1, builder.build()); + } catch (Exception e) { + log.debug("Failed to record flag evaluation metric for {}", flagKey, e); + } + } + + @Override + public void close() { + shutdown(); + } + + synchronized void shutdown() { + closed = true; + counter = null; + telemetry = null; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java new file mode 100644 index 00000000000..d63cbe56d8c --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java @@ -0,0 +1,41 @@ +package com.datadog.openfeature; + +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableMetadata; +import java.util.Map; + +class FlagEvalMetricsHook implements Hook { + + private final FlagEvalMetrics metrics; + + FlagEvalMetricsHook(FlagEvalMetrics metrics) { + this.metrics = metrics; + } + + @Override + public void finallyAfter( + HookContext ctx, FlagEvaluationDetails details, Map hints) { + if (metrics == null || details == null) { + return; + } + try { + String flagKey = details.getFlagKey(); + String variant = details.getVariant(); + String reason = details.getReason(); + ErrorCode errorCode = details.getErrorCode(); + + String allocationKey = null; + ImmutableMetadata metadata = details.getFlagMetadata(); + if (metadata != null) { + allocationKey = metadata.getString("allocationKey"); + } + + metrics.record(flagKey, variant, reason, errorCode, allocationKey); + } catch (Exception e) { + // Never let metrics recording break flag evaluation + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java b/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java new file mode 100644 index 00000000000..62f1ab751e5 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java @@ -0,0 +1,335 @@ +package com.datadog.openfeature; + +import static java.util.concurrent.TimeUnit.SECONDS; + +import com.datadog.openfeature.internal.FeatureFlagsRuntime; +import com.datadog.openfeature.internal.config.Settings; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.connector.Connectors; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.EventProvider; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.Metadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEvent; +import dev.openfeature.sdk.ProviderEventDetails; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.FatalError; +import dev.openfeature.sdk.exceptions.OpenFeatureError; +import dev.openfeature.sdk.exceptions.ProviderNotReadyError; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** The Datadog OpenFeature provider. */ +public class Provider extends EventProvider implements Metadata { + + private static final Logger log = LoggerFactory.getLogger(Provider.class); + static final String METADATA = "datadog-openfeature-provider"; + + private static final Options DEFAULT_OPTIONS = new Options().initTimeout(30, SECONDS); + private volatile Evaluator evaluator; + private final Options options; + private final Connector connector; + private final Settings settings; + private final AtomicReference initializationState = + new AtomicReference<>(InitializationState.NOT_STARTED); + private final FlagEvalMetrics flagEvalMetrics; + private final FlagEvalMetricsHook flagEvalMetricsHook; + // Span enrichment: null unless the gate is on, so the feature has no idle overhead when off. + private final SpanEnrichmentHook spanEnrichmentHook; + // Precomputed hook list returned by getProviderHooks() on every evaluation. Immutable and built + // once so gate-off evaluation allocates nothing on this hot path. + private final List providerHooks; + + /** Creates a provider waiting up to 30 seconds for the initial flag configuration. */ + public Provider() { + this(DEFAULT_OPTIONS, null); + } + + /** + * Creates a provider. + * + * @param options the provider options. + */ + public Provider(final Options options) { + this(options, null); + } + + Provider(final Options options, final Evaluator evaluator) { + this(options, evaluator, null); + } + + /** + * @param spanEnrichmentEnabledOverride when non-null, forces the span-enrichment gate (test + * seam); when null, the gate is read from the settings. + */ + Provider( + final Options options, + final Evaluator evaluator, + final Boolean spanEnrichmentEnabledOverride) { + this.options = options; + this.evaluator = evaluator; + this.connector = Connectors.detect(); + this.settings = Settings.load(this.connector); + FlagEvalMetrics metrics = null; + FlagEvalMetricsHook hook = null; + try { + metrics = new FlagEvalMetrics(); + hook = new FlagEvalMetricsHook(metrics); + } catch (LinkageError | Exception e) { + // This outer catch fires when the metrics helper itself can't load (OTel API absent). + log.warn("Evaluation metrics unavailable — OTel API classes not on classpath", e); + } + this.flagEvalMetrics = metrics; + this.flagEvalMetricsHook = hook; + + // Span enrichment is wired ONLY when the gate is on — off means no capture hook and no idle + // per-evaluation overhead. + final boolean spanEnrichmentEnabled = + spanEnrichmentEnabledOverride != null + ? spanEnrichmentEnabledOverride + : this.settings.isSpanEnrichmentEnabled(); + this.spanEnrichmentHook = + spanEnrichmentEnabled ? new SpanEnrichmentHook(this.connector.spanEnricher()) : null; + + // Precompute the immutable hook list once so getProviderHooks() (called on every evaluation) + // allocates nothing, including when the gate is off. + final List hooks = new ArrayList<>(3); + if (flagEvalMetricsHook != null) { + hooks.add(flagEvalMetricsHook); + } + // EVP flagevaluation hook: always registered; no-op when the pipeline is absent (killswitch + // off or provider not initialized). The pipeline is resolved lazily on each call. + hooks.add(new FlagEvalLoggingHook<>(this::flagEvaluationPipeline)); + if (spanEnrichmentHook != null) { + hooks.add(spanEnrichmentHook); + } + this.providerHooks = + hooks.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(hooks); + + // Announce the span-enrichment state at startup (matches the reference implementation). + // "enabled" only when the gate is on (the capture hook was constructed), otherwise "disabled". + if (spanEnrichmentHook != null) { + log.info("{} span enrichment enabled", METADATA); + } else { + log.info("{} span enrichment disabled", METADATA); + } + } + + @Override + public void initialize(final EvaluationContext context) throws Exception { + initializationState.set(InitializationState.INITIALIZING); + try { + evaluator = buildEvaluator(); + if (!evaluator.initialize(options.getTimeout(), options.getUnit(), context)) { + if (markInitialConfigReceivedReady()) { + return; + } + markInitializationError(); + throw new ProviderNotReadyError( + "Provider timed-out while waiting for initial configuration"); + } + if (!evaluator.hasConfiguration() || !markSuccessfulInitializationReady()) { + markInitializationError(); + throw new ProviderNotReadyError( + "Provider timed-out while waiting for initial configuration"); + } + } catch (final OpenFeatureError e) { + markInitializationError(); + throw e; + } catch (final Throwable e) { + markInitializationError(); + throw new FatalError( + "Failed to initialize Datadog Feature Flags provider: " + e.getMessage(), e); + } + } + + void onConfigurationChange() { + if (evaluator == null || !evaluator.hasConfiguration()) { + onConfigurationUnavailable(); + return; + } + + final InitializationState state = initializationState.get(); + if (state == InitializationState.INITIALIZING) { + initializationState.compareAndSet( + InitializationState.INITIALIZING, InitializationState.INITIAL_CONFIG_RECEIVED); + return; + } + if (state == InitializationState.INITIAL_CONFIG_RECEIVED) { + return; + } + if (state == InitializationState.ERROR + && initializationState.compareAndSet( + InitializationState.ERROR, InitializationState.READY)) { + emit( + ProviderEvent.PROVIDER_READY, + ProviderEventDetails.builder().message("Provider ready").build()); + return; + } + if (initializationState.get() != InitializationState.READY) { + return; + } + emit( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, + ProviderEventDetails.builder().message("New configuration received").build()); + } + + private void onConfigurationUnavailable() { + if (initializationState.compareAndSet( + InitializationState.INITIAL_CONFIG_RECEIVED, InitializationState.ERROR)) { + return; + } + if (!initializationState.compareAndSet(InitializationState.READY, InitializationState.ERROR)) { + return; + } + emit( + ProviderEvent.PROVIDER_ERROR, + ProviderEventDetails.builder() + .message("Configuration unavailable") + .errorCode(ErrorCode.PROVIDER_NOT_READY) + .build()); + } + + private boolean markInitialConfigReceivedReady() { + return initializationState.get() == InitializationState.READY + || initializationState.compareAndSet( + InitializationState.INITIAL_CONFIG_RECEIVED, InitializationState.READY); + } + + private boolean markSuccessfulInitializationReady() { + return markInitialConfigReceivedReady() + || initializationState.compareAndSet( + InitializationState.INITIALIZING, InitializationState.READY); + } + + private void markInitializationError() { + InitializationState state = initializationState.get(); + while (state != InitializationState.READY && state != InitializationState.ERROR) { + if (initializationState.compareAndSet(state, InitializationState.ERROR)) { + return; + } + state = initializationState.get(); + } + } + + private Evaluator buildEvaluator() { + if (evaluator != null) { + return evaluator; + } + return new DDEvaluator(this::onConfigurationChange, this.connector, this.settings); + } + + private FlagEvaluationWriter flagEvaluationPipeline() { + final Evaluator current = this.evaluator; + if (!(current instanceof DDEvaluator)) { + return null; + } + final FeatureFlagsRuntime runtime = ((DDEvaluator) current).runtime(); + return runtime == null ? null : runtime.evaluations(); + } + + @Override + public List getProviderHooks() { + return providerHooks; + } + + @Override + public void shutdown() { + if (flagEvalMetrics != null) { + flagEvalMetrics.shutdown(); + } + // Span enrichment needs no provider-close cleanup here: the capture hook holds no tracer state. + if (evaluator != null) { + evaluator.shutdown(); + } + } + + // Visible for tests: expose whether span enrichment is wired (gate-on) without leaking the impl. + SpanEnrichmentHook spanEnrichmentHook() { + return spanEnrichmentHook; + } + + @Override + public Metadata getMetadata() { + return this; + } + + @Override + public String getName() { + return METADATA; + } + + @Override + public ProviderEvaluation getBooleanEvaluation( + final String key, final Boolean defaultValue, final EvaluationContext ctx) { + return evaluator.evaluate(Boolean.class, key, defaultValue, ctx); + } + + @Override + public ProviderEvaluation getStringEvaluation( + final String key, final String defaultValue, final EvaluationContext ctx) { + return evaluator.evaluate(String.class, key, defaultValue, ctx); + } + + @Override + public ProviderEvaluation getIntegerEvaluation( + final String key, final Integer defaultValue, final EvaluationContext ctx) { + return evaluator.evaluate(Integer.class, key, defaultValue, ctx); + } + + @Override + public ProviderEvaluation getDoubleEvaluation( + final String key, final Double defaultValue, final EvaluationContext ctx) { + return evaluator.evaluate(Double.class, key, defaultValue, ctx); + } + + @Override + public ProviderEvaluation getObjectEvaluation( + final String key, final Value defaultValue, final EvaluationContext ctx) { + return evaluator.evaluate(Value.class, key, defaultValue, ctx); + } + + private enum InitializationState { + NOT_STARTED, + INITIALIZING, + INITIAL_CONFIG_RECEIVED, + READY, + ERROR + } + + /** The provider options. */ + public static class Options { + + private long timeout; + private TimeUnit unit; + + /** + * Sets how long provider initialization waits for the initial flag configuration. + * + * @param timeout the timeout. + * @param unit the timeout unit. + * @return these options. + */ + public Options initTimeout(final long timeout, final TimeUnit unit) { + this.timeout = timeout; + this.unit = unit; + return this; + } + + public long getTimeout() { + return timeout; + } + + public TimeUnit getUnit() { + return unit; + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java new file mode 100644 index 00000000000..525ba9badf9 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java @@ -0,0 +1,151 @@ +package com.datadog.openfeature; + +import com.datadog.openfeature.internal.connector.SpanEnricher; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.Structure; +import dev.openfeature.sdk.Value; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * OpenFeature {@code finally} hook that captures feature-flag evaluation metadata for APM span + * enrichment. Registered from {@link Provider#getProviderHooks()} only when the gate is on. For + * each relevant evaluation it records it to the {@link SpanEnricher}; the Datadog Java agent + * resolves the active local-root span and accumulates the per-trace state that is flushed onto the + * root when the trace completes. + * + *

Capture branch (frozen Node reference): + * + *

    + *
  • serial id present → dispatch a serial-id event (the write side records the serial id, plus + * the subject when {@code __dd_do_log} AND a targeting key) + *
  • else variant missing (runtime default) → dispatch a runtime-default event with the value + * unwrapped to a native Java type + *
+ * + *

All work is wrapped in try/catch — enrichment must NEVER break flag evaluation. + */ +class SpanEnrichmentHook implements Hook { + + private static final Logger log = LoggerFactory.getLogger(SpanEnrichmentHook.class); + + // The metadata keys the DDEvaluator attaches for span enrichment (single source of truth there). + static final String METADATA_SERIAL_ID = DDEvaluator.METADATA_SPLIT_SERIAL_ID; + static final String METADATA_DO_LOG = DDEvaluator.METADATA_DO_LOG; + + private final SpanEnricher enricher; + + SpanEnrichmentHook(final SpanEnricher enricher) { + this.enricher = enricher; + } + + @Override + public void finallyAfter( + final HookContext ctx, + final FlagEvaluationDetails details, + final Map hints) { + if (details == null) { + return; + } + try { + final ImmutableMetadata metadata = details.getFlagMetadata(); + final Integer serialId = metadata != null ? metadata.getInteger(METADATA_SERIAL_ID) : null; + if (serialId != null) { + final boolean doLog = Boolean.TRUE.equals(metadata.getBoolean(METADATA_DO_LOG)); + this.enricher.serialId(serialId, doLog, targetingKey(ctx)); + } else if (details.getVariant() == null) { + // Runtime-default detection = MISSING VARIANT (never a reason enum). Unwrap any OpenFeature + // Value to a native Java type here so the connector carries only JDK types. + this.enricher.runtimeDefault(details.getFlagKey(), unwrapDefaultValue(details.getValue())); + } + } catch (final Throwable t) { + // Never let span enrichment break flag evaluation; a debug line aids diagnosis if it does. + log.debug("Span-enrichment capture failed for flag {}", details.getFlagKey(), t); + } + } + + private static String targetingKey(final HookContext ctx) { + if (ctx == null) { + return null; + } + final EvaluationContext evaluationContext = ctx.getCtx(); + if (evaluationContext == null) { + return null; + } + final String key = evaluationContext.getTargetingKey(); + return (key == null || key.isEmpty()) ? null : key; + } + + /** + * Unwraps an OpenFeature {@link Value} to a native Java representation so the runtime default can + * cross the (JDK-types-only) seam and be JSON-serialized on the write side exactly like Node's + * {@code JSON.stringify}. Non-{@code Value} inputs (already native) are returned as-is. + */ + static Object unwrapDefaultValue(final Object value) { + return value instanceof Value ? unwrapValue((Value) value) : value; + } + + /** + * Recursively unwraps an OpenFeature {@link Value} into its native Java representation: + * structures become {@code Map}, lists become {@code List}, and scalars + * become their boxed value (or {@code null}). Nested {@link Value}s are unwrapped at every level + * so a structure containing further structures/lists serializes correctly. + */ + private static Object unwrapValue(final Value value) { + if (value == null || value.isNull()) { + return null; + } + if (value.isStructure()) { + final Structure structure = value.asStructure(); + final Map map = new LinkedHashMap<>(); + if (structure != null) { + for (final String key : structure.keySet()) { + map.put(key, unwrapValue(structure.getValue(key))); + } + } + return map; + } + if (value.isList()) { + final List list = value.asList(); + final List out = new ArrayList<>(list == null ? 0 : list.size()); + if (list != null) { + for (final Value element : list) { + out.add(unwrapValue(element)); + } + } + return out; + } + if (value.isBoolean()) { + return value.asBoolean(); + } + if (value.isString()) { + return value.asString(); + } + if (value.isNumber()) { + // Preserve integral vs fractional so the rendered JSON number matches Node. + final Double d = value.asDouble(); + if (d != null && d == Math.rint(d) && !Double.isInfinite(d)) { + final Integer i = value.asInteger(); + if (i != null) { + return i; + } + } + return d; + } + final Instant instant = value.asInstant(); + if (instant != null) { + return instant.toString(); + } + // Unknown shape: fall back to the wrapped object's own representation. + return value.asObject(); + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java new file mode 100644 index 00000000000..b787eb0488c --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java @@ -0,0 +1,232 @@ +package com.datadog.openfeature.internal; + +import static com.datadog.openfeature.internal.config.Settings.CONFIGURATION_SOURCE_REMOTE_CONFIG; + +import com.datadog.openfeature.internal.config.Settings; +import com.datadog.openfeature.internal.connector.ConfigurationSource; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.delivery.CdnConfigurationSource; +import com.datadog.openfeature.internal.delivery.DirectIntakeTransport; +import com.datadog.openfeature.internal.delivery.FallbackTransport; +import com.datadog.openfeature.internal.delivery.RemoteConfigurationService; +import com.datadog.openfeature.internal.exposure.ExposureEvent; +import com.datadog.openfeature.internal.exposure.ExposurePipeline; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationPipeline; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The Feature Flags runtime: configuration delivery and event pipelines. A single runtime is shared + * by all the providers of a class loader, and stopped when the last one releases it. + */ +public final class FeatureFlagsRuntime { + private static final Logger LOGGER = LoggerFactory.getLogger(FeatureFlagsRuntime.class); + + private static final Object SHARED_LOCK = new Object(); + private static FeatureFlagsRuntime shared; + private static int references; + + private final ConfigurationService source; + @Nullable private final ExposurePipeline exposures; + @Nullable private final FlagEvaluationPipeline evaluations; + private final List> listeners = new CopyOnWriteArrayList<>(); + private volatile ServerConfiguration configuration; + + FeatureFlagsRuntime( + final ConfigurationService source, + @Nullable final ExposurePipeline exposures, + @Nullable final FlagEvaluationPipeline evaluations) { + this.source = source; + this.exposures = exposures; + this.evaluations = evaluations; + } + + /** + * Acquires the shared runtime, starting it on first acquisition. Each acquisition must be paired + * with a {@link #release()}. + * + * @param connector the connector to the Datadog Java agent. + * @param settings the SDK settings. + * @return the shared runtime. + * @throws IllegalStateException if Feature Flags is disabled or misconfigured. + */ + public static FeatureFlagsRuntime acquire(final Connector connector, final Settings settings) { + synchronized (SHARED_LOCK) { + if (shared == null) { + final FeatureFlagsRuntime created = create(connector, settings); + created.start(); + shared = created; + } + references++; + return shared; + } + } + + /** Releases the shared runtime, stopping it on last release. */ + public static void release() { + final FeatureFlagsRuntime stopped; + synchronized (SHARED_LOCK) { + if (references == 0 || --references > 0) { + return; + } + stopped = shared; + shared = null; + } + stopped.stop(); + } + + static FeatureFlagsRuntime create(final Connector connector, final Settings settings) { + final String sourceName = settings.configurationSource(); + if (sourceName == null) { + throw new IllegalStateException("Feature Flags is disabled"); + } + final boolean remoteConfig = CONFIGURATION_SOURCE_REMOTE_CONFIG.equals(sourceName); + final RuntimeServices services = new RuntimeServices(connector.healthMetrics()); + final ConfigurationService source; + final EventTransport transport; + if (remoteConfig) { + // TODO Support Remote Configuration without the Datadog Java agent. + final ConfigurationSource remoteConfiguration = connector.remoteConfiguration(); + if (remoteConfiguration == null) { + throw new IllegalStateException( + "Feature Flags Remote Configuration source requires the Datadog Java agent with" + + " Remote Configuration enabled"); + } + source = new RemoteConfigurationService(remoteConfiguration); + // Remote Configuration never falls back to direct intake. + transport = connector.eventProxy(); + } else { + source = new CdnConfigurationSource(settings, services); + final EventTransport proxy = connector.eventProxy(); + final EventTransport direct = DirectIntakeTransport.create(settings); + transport = + proxy == null ? direct : direct == null ? proxy : new FallbackTransport(proxy, direct); + } + if (transport == null) { + LOGGER.warn( + "Feature Flags event delivery is disabled: set DD_API_KEY or attach the Datadog Java agent"); + return new FeatureFlagsRuntime(source, null, null); + } + final Map context = eventContext(settings); + final ExposurePipeline exposures = + new ExposurePipeline( + ExposurePipeline.DEFAULT_CAPACITY, + ExposurePipeline.DEFAULT_FLUSH_INTERVAL_IN_SECONDS, + TimeUnit.SECONDS, + transport, + context, + services); + final FlagEvaluationPipeline evaluations = + settings.isEvaluationCountsEnabled() + ? new FlagEvaluationPipeline( + FlagEvaluationPipeline.DEFAULT_CAPACITY, + FlagEvaluationPipeline.FLUSH_INTERVAL_SECONDS, + TimeUnit.SECONDS, + transport, + context, + services) + : null; + return new FeatureFlagsRuntime(source, exposures, evaluations); + } + + private static Map eventContext(final Settings settings) { + final Map context = new HashMap<>(); + putIfNotNull(context, "service", settings.service()); + putIfNotNull(context, "env", settings.env()); + putIfNotNull(context, "version", settings.version()); + return context; + } + + private static void putIfNotNull( + final Map map, final String key, @Nullable final String value) { + if (value != null) { + map.put(key, value); + } + } + + void start() { + try { + if (this.exposures != null) { + this.exposures.start(); + } + if (this.evaluations != null) { + this.evaluations.start(); + } + this.source.start(this::onConfiguration); + } catch (final RuntimeException | Error e) { + stop(); + throw e; + } + } + + void stop() { + this.source.close(); + if (this.evaluations != null) { + this.evaluations.close(); + } + if (this.exposures != null) { + this.exposures.close(); + } + } + + private void onConfiguration(@Nullable final ServerConfiguration configuration) { + this.configuration = configuration; + for (final Consumer listener : this.listeners) { + try { + listener.accept(configuration); + } catch (final RuntimeException e) { + LOGGER.warn("Feature Flags configuration listener failed", e); + } + } + } + + /** + * Adds a configuration listener, receiving the current configuration if any. + * + * @param listener the listener to add. + */ + public void addConfigurationListener(final Consumer listener) { + this.listeners.add(listener); + final ServerConfiguration current = this.configuration; + if (current != null) { + listener.accept(current); + } + } + + /** + * Removes a configuration listener. + * + * @param listener the listener to remove. + */ + public void removeConfigurationListener(final Consumer listener) { + this.listeners.remove(listener); + } + + /** + * Records an exposure, if exposure delivery is enabled. + * + * @param exposure the exposure to record. + */ + public void recordExposure(final ExposureEvent exposure) { + if (this.exposures != null) { + this.exposures.accept(exposure); + } + } + + /** + * @return the flag evaluation pipeline, or {@code null} if evaluation counts are disabled. + */ + @Nullable + public FlagEvaluationPipeline evaluations() { + return this.evaluations; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/ConfigurationStore.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/ConfigurationStore.java new file mode 100644 index 00000000000..391912dd226 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/ConfigurationStore.java @@ -0,0 +1,32 @@ +package com.datadog.openfeature.internal.evaluation; + +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** Atomically publishes one validated snapshot for synchronous evaluation. */ +public final class ConfigurationStore { + private final AtomicReference current = new AtomicReference<>(); + private final CountDownLatch ready = new CountDownLatch(1); + + public ServerConfiguration get() { + return current.get(); + } + + public void set(ServerConfiguration configuration) { + current.set(configuration); + } + + public void markReady() { + ready.countDown(); + } + + public boolean wasReady() { + return ready.getCount() == 0; + } + + public boolean await(long timeout, TimeUnit unit) throws InterruptedException { + return ready.await(timeout, unit); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java new file mode 100644 index 00000000000..e8fc19c1fcb --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java @@ -0,0 +1,205 @@ +package com.datadog.openfeature; + +import static java.util.Collections.emptyMap; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.greaterThan; +import static org.mockito.Mockito.mock; + +import com.datadog.openfeature.internal.JsonReading; +import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.ufc.UniversalFlagConfigParser; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Value; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Evaluates the cross-SDK canonical fixtures from the {@code ffe-system-test-data} submodule, + * parsed with the production UFC parser. + */ +class CanonicalFixtureTest { + private static final String CANONICAL_FIXTURE_PATH = + "dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data"; + + @Test + void canonicalFixturesArePresent() throws IOException { + assertThat(canonicalTestCases().size(), greaterThan(0)); + } + + @MethodSource("canonicalTestCases") + @ParameterizedTest(name = "{0}") + void evaluateCanonicalFixture(final FixtureCase testCase) throws IOException { + final DDEvaluator evaluator = + new DDEvaluator(mock(Runnable.class), Connector.NONE, TestSettings.of()); + evaluator.accept( + UniversalFlagConfigParser.parse( + Files.readAllBytes(fixtureRoot().resolve("ufc-config.json")))); + + final Class targetType = targetType(testCase.variationType); + final Object defaultValue = DDEvaluator.mapValue(targetType, testCase.defaultValue); + final Object expectedValue = DDEvaluator.mapValue(targetType, testCase.resultValue); + final ProviderEvaluation details = + evaluate(evaluator, targetType, testCase.flag, defaultValue, context(testCase)); + + assertThat(details.getValue(), equalTo(expectedValue)); + assertThat(details.getReason(), equalTo(testCase.resultReason)); + if (testCase.resultVariant != null) { + assertThat(details.getVariant(), equalTo(testCase.resultVariant)); + } + if (testCase.resultErrorCode != null) { + assertThat(details.getErrorCode(), equalTo(ErrorCode.valueOf(testCase.resultErrorCode))); + } + if (testCase.resultAllocationKey != null) { + assertThat( + details.getFlagMetadata().getString("allocationKey"), + equalTo(testCase.resultAllocationKey)); + } + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static ProviderEvaluation evaluate( + final DDEvaluator evaluator, + final Class targetType, + final String flag, + final Object defaultValue, + final EvaluationContext context) { + return evaluator.evaluate((Class) targetType, flag, defaultValue, context); + } + + @SuppressWarnings("unchecked") + static List canonicalTestCases() throws IOException { + final Path evaluationCases = fixtureRoot().resolve("evaluation-cases"); + final List result = new ArrayList<>(); + try (Stream paths = Files.list(evaluationCases)) { + final List files = + paths + .filter(path -> path.getFileName().toString().endsWith(".json")) + .sorted() + .collect(Collectors.toList()); + for (final Path file : files) { + final List testCases = (List) JsonReading.read(Files.readAllBytes(file)); + for (int index = 0; index < testCases.size(); index++) { + result.add( + new FixtureCase( + file.getFileName().toString(), + index, + (Map) testCases.get(index))); + } + } + } + assertThat(result.size(), greaterThan(0)); + return result; + } + + private static Path fixtureRoot() { + Path directory = Paths.get("").toAbsolutePath(); + while (directory != null) { + final Path candidate = directory.resolve(CANONICAL_FIXTURE_PATH); + if (Files.exists(candidate.resolve("ufc-config.json")) + && Files.isDirectory(candidate.resolve("evaluation-cases"))) { + return candidate; + } + directory = directory.getParent(); + } + throw new IllegalStateException("Unable to find canonical FFE fixtures"); + } + + private static EvaluationContext context(final FixtureCase testCase) { + final MutableContext context = + new MutableContext(Value.objectToValue(testCase.attributes).asStructure().asMap()); + if (testCase.targetingKey != null) { + context.setTargetingKey(testCase.targetingKey); + } + return context; + } + + private static Class targetType(final String variationType) { + switch (variationType) { + case "BOOLEAN": + return Boolean.class; + case "INTEGER": + return Integer.class; + case "NUMERIC": + return Double.class; + case "STRING": + return String.class; + case "JSON": + return Value.class; + default: + throw new IllegalArgumentException("Unsupported variationType: " + variationType); + } + } + + /** Normalizes the integral numbers read as Long to Double, as the UFC parser does. */ + private static Object normalize(final Object value) { + if (value instanceof Long) { + return ((Long) value).doubleValue(); + } + if (value instanceof Map) { + final Map map = new java.util.LinkedHashMap<>(); + ((Map) value).forEach((k, v) -> map.put((String) k, normalize(v))); + return map; + } + if (value instanceof List) { + final List list = new ArrayList<>(); + ((List) value).forEach(v -> list.add(normalize(v))); + return list; + } + return value; + } + + static final class FixtureCase { + final String fileName; + final int index; + final Map attributes; + final Object defaultValue; + final String flag; + final String targetingKey; + final String variationType; + final Object resultValue; + final String resultReason; + final String resultErrorCode; + final String resultVariant; + final String resultAllocationKey; + + @SuppressWarnings("unchecked") + FixtureCase(final String fileName, final int index, final Map json) { + this.fileName = fileName; + this.index = index; + final Object attributes = normalize(json.get("attributes")); + this.attributes = attributes == null ? emptyMap() : (Map) attributes; + this.defaultValue = normalize(json.get("defaultValue")); + this.flag = (String) json.get("flag"); + this.targetingKey = (String) json.get("targetingKey"); + this.variationType = (String) json.get("variationType"); + final Map result = (Map) json.get("result"); + this.resultValue = normalize(result.get("value")); + this.resultReason = (String) result.get("reason"); + this.resultErrorCode = (String) result.get("errorCode"); + this.resultVariant = (String) result.get("variant"); + final Map metadata = (Map) result.get("flagMetadata"); + final Object allocationKey = metadata == null ? null : metadata.get("allocationKey"); + this.resultAllocationKey = allocationKey == null ? null : String.valueOf(allocationKey); + } + + @Override + public String toString() { + return this.fileName + "[" + this.index + "] flag=" + this.flag; + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorSpanEnrichmentTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorSpanEnrichmentTest.java new file mode 100644 index 00000000000..923648f8a79 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorSpanEnrichmentTest.java @@ -0,0 +1,67 @@ +package com.datadog.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 com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.Flag; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.Split; +import com.datadog.openfeature.internal.ufc.ValueType; +import com.datadog.openfeature.internal.ufc.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}, enabled through its settings. */ +class DDEvaluatorSpanEnrichmentTest { + + @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)); + } + + @Test + void enrichmentMetadataOmitsTheSerialIdWhenTheSplitHasNone() { + assertNull(evaluate(null).getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); + } + + private static ProviderEvaluation evaluate(final Integer serialId) { + final Map 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 flags = new HashMap<>(); + flags.put( + "target", + new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation))); + + final DDEvaluator evaluator = + new DDEvaluator( + mock(Runnable.class), + Connector.NONE, + TestSettings.of("experimental.flagging.provider.span.enrichment.enabled", "true")); + evaluator.accept(new ServerConfiguration("", "", true, null, flags)); + + final EvaluationContext ctx = new MutableContext("target").setTargetingKey("user-1"); + return evaluator.evaluate(Integer.class, "target", 23, ctx); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java new file mode 100644 index 00000000000..507d1bca061 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java @@ -0,0 +1,984 @@ +package com.datadog.openfeature; + +import static dev.openfeature.sdk.Reason.ERROR; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +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.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.datadog.openfeature.internal.LocalHttpServer; +import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.exposure.ExposureEvent; +import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.ConditionConfiguration; +import com.datadog.openfeature.internal.ufc.ConditionOperator; +import com.datadog.openfeature.internal.ufc.Flag; +import com.datadog.openfeature.internal.ufc.ParsedSemver; +import com.datadog.openfeature.internal.ufc.Rule; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.Shard; +import com.datadog.openfeature.internal.ufc.ShardRange; +import com.datadog.openfeature.internal.ufc.Split; +import com.datadog.openfeature.internal.ufc.ValueType; +import com.datadog.openfeature.internal.ufc.Variant; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Value; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public class DDEvaluatorTest { + + private static DDEvaluator newEvaluator(final Runnable configCallback) { + return new DDEvaluator(configCallback, Connector.NONE, TestSettings.of()); + } + + /** Creates an evaluator whose runtime polls a local CDN that has no configuration. */ + private static DDEvaluator newInitializableEvaluator( + final Runnable configCallback, final LocalHttpServer cdn) { + return new DDEvaluator( + configCallback, + Connector.NONE, + TestSettings.of( + "feature.flags.configuration.source.agentless.base.url", + cdn.uri("/").toString(), + "flagging.evaluation.counts.enabled", + "false")); + } + + private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; + + private static Arguments[] valueMappingTestCases() { + return new Arguments[] { + // String mappings + Arguments.of(String.class, "hello", "hello"), + Arguments.of(String.class, 123, "123"), + Arguments.of(String.class, true, "true"), + Arguments.of(String.class, 3.14, "3.14"), + Arguments.of(String.class, null, null), + + // Boolean mappings + Arguments.of(Boolean.class, true, true), + Arguments.of(Boolean.class, false, false), + Arguments.of(Boolean.class, "true", true), + Arguments.of(Boolean.class, "false", false), + Arguments.of(Boolean.class, "TRUE", true), + Arguments.of(Boolean.class, "FALSE", false), + Arguments.of(Boolean.class, 1, true), + Arguments.of(Boolean.class, 0, false), + Arguments.of(Boolean.class, null, null), + + // Integer mappings + Arguments.of(Integer.class, 42, 42), + Arguments.of(Integer.class, "42", 42), + Arguments.of(Integer.class, 3.14, 3), + Arguments.of(Integer.class, "3.14", 3), + Arguments.of(Integer.class, null, null), + + // Double mappings + Arguments.of(Double.class, 3.14, 3.14), + Arguments.of(Double.class, "3.14", 3.14), + Arguments.of(Double.class, 42, 42.0), + Arguments.of(Double.class, "42", 42.0), + Arguments.of(Double.class, null, null), + + // Value mappings (OpenFeature Value objects) + Arguments.of(Value.class, "hello", Value.objectToValue("hello")), + Arguments.of(Value.class, 42, Value.objectToValue(42)), + Arguments.of(Value.class, 3.14, Value.objectToValue(3.14)), + Arguments.of(Value.class, true, Value.objectToValue(true)), + Arguments.of(Value.class, null, null), + + // Unsupported + Arguments.of(Long.class, 42L, IllegalArgumentException.class), + }; + } + + @ParameterizedTest + @MethodSource("valueMappingTestCases") + public void testValueMapping(final Class target, final Object value, final Object expected) { + if (expected == IllegalArgumentException.class) { + assertThrows(IllegalArgumentException.class, () -> DDEvaluator.mapValue(target, value)); + } else { + final Object result = DDEvaluator.mapValue(target, value); + assertThat(result, equalTo(expected)); + } + } + + @Test + public void testEvaluateNoConfig() { + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + final ProviderEvaluation details = + evaluator.evaluate(Integer.class, "test", 23, mock(EvaluationContext.class)); + assertThat(details.getValue(), equalTo(23)); + assertThat(details.getReason(), equalTo(ERROR.name())); + assertThat(details.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); + } + + @Test + public void testInitializeTimesOutWithoutConfig() throws Exception { + final Runnable configCallback = mock(Runnable.class); + try (LocalHttpServer cdn = new LocalHttpServer()) { + final DDEvaluator evaluator = newInitializableEvaluator(configCallback, cdn); + evaluator.accept(null); + try { + assertThat( + evaluator.initialize(10, MILLISECONDS, mock(EvaluationContext.class)), equalTo(false)); + verify(configCallback, times(0)).run(); + } finally { + evaluator.shutdown(); + } + } + } + + @Test + public void testInitializeWaitsForNonNullConfig() throws Exception { + try (LocalHttpServer cdn = new LocalHttpServer()) { + final DDEvaluator evaluator = newInitializableEvaluator(mock(Runnable.class), cdn); + final ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + final Future initialized = + executor.submit(() -> evaluator.initialize(5, SECONDS, mock(EvaluationContext.class))); + + evaluator.accept(null); + assertThat(initialized.isDone(), equalTo(false)); + + evaluator.accept(new ServerConfiguration("", "", false, null, emptyMap())); + assertThat(initialized.get(5, SECONDS), equalTo(true)); + } finally { + executor.shutdownNow(); + evaluator.shutdown(); + } + } + } + + @Test + public void testEvaluateNoContext() { + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", false, null, emptyMap())); + final ProviderEvaluation details = evaluator.evaluate(Integer.class, "test", 23, null); + assertThat(details.getValue(), equalTo(23)); + assertThat(details.getReason(), equalTo(ERROR.name())); + assertThat(details.getErrorCode(), equalTo(ErrorCode.INVALID_CONTEXT)); + } + + @Test + public void testNoAllocations() { + final Map flags = new HashMap<>(); + flags.put("null-allocation", new Flag("target", true, null, null, null)); + flags.put("empty-allocation", new Flag("target", true, null, null, emptyList())); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", false, null, flags)); + + final EvaluationContext ctx = new MutableContext("target").setTargetingKey("allocation"); + + ProviderEvaluation details = evaluator.evaluate(Integer.class, "null-allocation", 23, ctx); + assertThat(details.getValue(), equalTo(23)); + assertThat(details.getReason(), equalTo(ERROR.name())); + assertThat(details.getErrorCode(), equalTo(ErrorCode.GENERAL)); + + details = evaluator.evaluate(Integer.class, "empty-allocation", 23, ctx); + assertThat(details.getValue(), equalTo(23)); + assertThat(details.getReason(), equalTo("DEFAULT")); + assertThat(details.getErrorCode(), nullValue()); + } + + @Test + public void testEvaluateUnsignedShardRange() { + final Map variations = new HashMap<>(); + variations.put("on", new Variant("on", 1)); + // The selected shard is above Integer.MAX_VALUE, so this test proves that evaluation uses + // unsigned 32-bit semantics. + final Shard shard = + new Shard( + "salt", + singletonList(new ShardRange(3_699_531_192L, 3_699_531_193L)), + MAX_UNSIGNED_INT); + final Split split = new Split(singletonList(shard), "on", emptyMap(), null); + final Allocation allocation = + new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.FALSE); + final Map flags = new HashMap<>(); + flags.put( + "target", + new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation))); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", false, null, flags)); + + final EvaluationContext context = + new MutableContext("target").setTargetingKey("high-shard-user"); + final ProviderEvaluation details = evaluator.evaluate(Integer.class, "target", 23, context); + + assertThat(details.getValue(), equalTo(1)); + assertThat(details.getReason(), equalTo("SPLIT")); + assertThat(details.getVariant(), equalTo("on")); + } + + // ---- observeFullEvaluationData metadata is stamped from the evaluator's ServerConfiguration + // ---- + // + // Every code path that returns a ProviderEvaluation must stamp the consent boolean so downstream + // hooks can honour it. These tests exercise each stamp site with both consent values (on/off) so + // a mutation to any stamp — deleting the line, hardcoding the value — flips at least one + // assertion. + + // -- success path: resolveVariant (variant metadata builder) -- + + @Test + public void observeFullEvaluationDataStampedTrueOnResolvedVariant() { + final ProviderEvaluation details = evaluateMatchingFlag(true); + + assertThat(details.getReason(), equalTo("STATIC")); + assertThat(details.getVariant(), equalTo("on")); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(true)); + } + + @Test + public void observeFullEvaluationDataStampedFalseOnResolvedVariant() { + // Symmetric consent-off assertion. Paired with the consent-on test above this pins the + // resolveVariant metadata line (DDEvaluator.java: METADATA_OBSERVE_FULL_EVALUATION_DATA) so + // deleting it or hardcoding either value would fail at least one assertion. + final ProviderEvaluation details = evaluateMatchingFlag(false); + + assertThat(details.getReason(), equalTo("STATIC")); + assertThat(details.getVariant(), equalTo("on")); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(false)); + } + + // -- DISABLED path: flag.enabled=false -- + + @Test + public void observeFullEvaluationDataStampedTrueOnDisabledFlag() { + final ProviderEvaluation details = evaluateDisabledFlag(true); + + assertThat(details.getReason(), equalTo("DISABLED")); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(true)); + } + + @Test + public void observeFullEvaluationDataStampedFalseOnDisabledFlag() { + final ProviderEvaluation details = evaluateDisabledFlag(false); + + assertThat(details.getReason(), equalTo("DISABLED")); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(false)); + } + + // -- DEFAULT path: no allocation matches -- + + @Test + public void observeFullEvaluationDataStampedTrueOnDefault() { + // Allocation exists but has empty splits, so the loop finishes without returning and we fall + // through to the DEFAULT branch. + final ProviderEvaluation details = evaluateWithEmptySplits(true); + + assertThat(details.getReason(), equalTo("DEFAULT")); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(true)); + } + + @Test + public void observeFullEvaluationDataStampedFalseOnDefault() { + final ProviderEvaluation details = evaluateWithEmptySplits(false); + + assertThat(details.getReason(), equalTo("DEFAULT")); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(false)); + } + + // -- error paths: FLAG_NOT_FOUND / PROVIDER_NOT_READY (via consentMetadata in error()) -- + + @Test + public void observeFullEvaluationDataStampedOnFlagNotFoundError() { + // Was previously named "…OnSuccess" but actually exercises the error() helper's stamp via + // FLAG_NOT_FOUND — kept for that stamp site, correctly named. + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", true, null, new HashMap<>())); + + final EvaluationContext ctx = new MutableContext("target").setTargetingKey("k"); + final ProviderEvaluation details = + evaluator.evaluate(Integer.class, "unknown-flag", 23, ctx); + + assertThat(details.getErrorCode(), equalTo(ErrorCode.FLAG_NOT_FOUND)); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(true)); + } + + @Test + public void observeFullEvaluationDataDefaultsToFalseWhenEvaluatorHasNoConfig() { + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + final ProviderEvaluation details = + evaluator.evaluate(Integer.class, "test", 23, mock(EvaluationContext.class)); + assertThat(details.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(false)); + } + + @Test + public void observeFullEvaluationDataNullConfigFieldTreatedAsFalse() { + // The field is boxed so the parser tolerates a malformed consent value in the UFC JSON without + // aborting the whole parse. The evaluator must then interpret null as the privacy-preserving + // default. An auto-unbox at the read site (config.observeFullEvaluationData) would NPE here. + final Map flags = new HashMap<>(); + flags.put("target", new Flag("target", true, ValueType.INTEGER, emptyMap(), emptyList())); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", null, null, flags)); + + final EvaluationContext ctx = new MutableContext("target").setTargetingKey("k"); + final ProviderEvaluation details = evaluator.evaluate(Integer.class, "target", 23, ctx); + + // Flags still evaluate — availability preserved despite the malformed consent field. + assertThat(details.getReason(), equalTo("DEFAULT")); + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + 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); + } + + /** + * 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 dispatched = new ArrayList<>(); + final DDEvaluator evaluator = + new DDEvaluator(mock(Runnable.class), Connector.NONE, TestSettings.of()) { + @Override + void recordExposure(final ExposureEvent event) { + dispatched.add(event); + } + }; + final Map 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); + final Map flags = new HashMap<>(); + flags.put( + "target", + new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation))); + evaluator.accept(new ServerConfiguration("", "", true, null, flags)); + evaluator.evaluate( + Integer.class, "target", 23, new MutableContext("target").setTargetingKey("user-1")); + 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. + private static ProviderEvaluation evaluateMatchingFlag( + final boolean observeFullEvaluationData) { + final Map variations = new HashMap<>(); + variations.put("on", new Variant("on", 1)); + final Split split = new Split(emptyList(), "on", emptyMap(), null); + final Allocation allocation = + new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.FALSE); + return evaluateFlag( + new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation)), + observeFullEvaluationData); + } + + private static ProviderEvaluation evaluateDisabledFlag( + final boolean observeFullEvaluationData) { + return evaluateFlag( + new Flag("target", false, ValueType.INTEGER, emptyMap(), null), observeFullEvaluationData); + } + + private static ProviderEvaluation evaluateWithEmptySplits( + final boolean observeFullEvaluationData) { + // Enabled, allocations present, allocation active, no rules, empty splits → falls through the + // for-loop to the DEFAULT return. + final Allocation allocation = + new Allocation("alloc-1", null, null, null, emptyList(), Boolean.FALSE); + return evaluateFlag( + new Flag("target", true, ValueType.INTEGER, emptyMap(), singletonList(allocation)), + observeFullEvaluationData); + } + + private static ProviderEvaluation evaluateFlag( + final Flag flag, final boolean observeFullEvaluationData) { + final Map flags = new HashMap<>(); + flags.put("target", flag); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); + + final EvaluationContext ctx = new MutableContext("target").setTargetingKey("user-1"); + return evaluator.evaluate(Integer.class, "target", 23, ctx); + } + + // ---- error message redaction respects observeFullEvaluationData ---- + + @Test + public void numericConditionOnTargetingKeyDropsExceptionMessageUnderConsentOff() { + // Rule {attribute:"id", operator:GT, value:0} + "id" not in context → + // DDEvaluator.resolveAttribute + // falls back to the targeting key, so Double.parseDouble("jane.doe@datadoghq.com") throws + // NumberFormatException. The exception message echoes the raw context value verbatim, so it + // must be dropped when observeFullEvaluationData=false. + final ProviderEvaluation details = + evaluateWithNumericRuleOnId("jane.doe@datadoghq.com", false); + + assertThat(details.getErrorCode(), equalTo(ErrorCode.TYPE_MISMATCH)); + assertNull(details.getErrorMessage(), "consent-off must not surface the raw exception message"); + } + + @Test + public void numericConditionOnTargetingKeyPreservesExceptionMessageUnderConsentOn() { + // Symmetric case: with consent on, the raw exception message flows through unchanged so + // operators keep the diagnostic detail they opted in to. + final ProviderEvaluation details = + evaluateWithNumericRuleOnId("jane.doe@datadoghq.com", true); + + assertThat(details.getErrorCode(), equalTo(ErrorCode.TYPE_MISMATCH)); + assertThat(details.getErrorMessage(), equalTo("For input string: \"jane.doe@datadoghq.com\"")); + } + + @Test + public void numericConditionOnTargetingKeyErrorMessageNeverContainsPiiUnderConsentOff() { + // Belt-and-suspenders: independent of the exact null/empty form, the raw PII value must never + // appear in the message under consent-off. Guards against future changes that might replace + // null with a redacted string or a code-name suffix. + final ProviderEvaluation details = + evaluateWithNumericRuleOnId("jane.doe@datadoghq.com", false); + + final String message = details.getErrorMessage(); + assertFalse( + message != null && message.contains("jane.doe@datadoghq.com"), + "consent-off errorMessage must not contain raw context values"); + } + + private static ProviderEvaluation evaluateWithNumericRuleOnId( + final String targetingKey, final boolean observeFullEvaluationData) { + final Map flags = new HashMap<>(); + final List rules = + singletonList( + new Rule(singletonList(new ConditionConfiguration(ConditionOperator.GT, "id", 0)))); + // Split must be non-empty so the allocation is considered a match target; its contents don't + // matter because the rule throws before a split is picked. + final Allocation allocation = + new Allocation("alloc", rules, null, null, emptyList(), Boolean.FALSE); + flags.put( + "num-rule", + new Flag("num-rule", true, ValueType.INTEGER, emptyMap(), singletonList(allocation))); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); + + final EvaluationContext ctx = new MutableContext(targetingKey); + return evaluator.evaluate(Integer.class, "num-rule", 23, ctx); + } + + @Test + public void testAllocationWindowHonorsMicrosecondPrecision() { + final Instant startAt = Instant.parse("2024-01-01T00:00:00.123456Z"); + final Instant endAt = Instant.parse("2024-01-01T00:00:00.987654Z"); + final Allocation allocation = + new Allocation("allocation", emptyList(), startAt, endAt, emptyList(), true); + + assertThat( + DDEvaluator.isAllocationActive(allocation, startAt.minusNanos(1_000)), equalTo(false)); + assertThat(DDEvaluator.isAllocationActive(allocation, startAt), equalTo(true)); + assertThat(DDEvaluator.isAllocationActive(allocation, endAt), equalTo(true)); + assertThat(DDEvaluator.isAllocationActive(allocation, endAt.plusNanos(1_000)), equalTo(false)); + } + + // --- SemVer condition evaluation tests (ported from Go evaluator_test.go) --- + + private static Flag semverFlag(final ConditionOperator operator, final String comparand) { + final ParsedSemver parsed = ParsedSemver.parse(comparand); + final ConditionConfiguration condition = + new ConditionConfiguration(operator, "version", comparand); + condition.semverComparand = parsed; + final Rule rule = new Rule(singletonList(condition)); + final Split split = new Split(emptyList(), "on", null, null); + final Allocation allocation = + new Allocation("targeted", singletonList(rule), null, null, singletonList(split), false); + final Map variations = new HashMap<>(); + variations.put("on", new Variant("on", true)); + return new Flag("test-flag", true, ValueType.BOOLEAN, variations, singletonList(allocation)); + } + + private static EvaluationContext semverContext(final Object version) { + final Map attributes = new HashMap<>(); + if (version != null) { + attributes.put("version", version); + } + final MutableContext context = + new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); + context.setTargetingKey("subject"); + return context; + } + + static Arguments[] semverConditionTestCases() { + return new Arguments[] { + // Equal + Arguments.of(ConditionOperator.SEMVER_EQ, "1.2.3", "1.2.3", true), + Arguments.of(ConditionOperator.SEMVER_EQ, "1.2.4", "1.2.3", false), + Arguments.of(ConditionOperator.SEMVER_EQ, "1.2.3.4.5.6", "1.2.3.4.5.6", true), + Arguments.of(ConditionOperator.SEMVER_GT, "1.2.3.4.5.7", "1.2.3.4.5.6", true), + // Not equal + Arguments.of(ConditionOperator.SEMVER_NEQ, "1.2.4", "1.2.3", true), + Arguments.of(ConditionOperator.SEMVER_NEQ, "1.2.3", "1.2.3", false), + // Less than + Arguments.of(ConditionOperator.SEMVER_LT, "1.9.9", "2.0.0", true), + Arguments.of(ConditionOperator.SEMVER_LT, "2.0.0", "2.0.0", false), + // Less than or equal + Arguments.of(ConditionOperator.SEMVER_LTE, "2.0.0", "2.0.0", true), + Arguments.of(ConditionOperator.SEMVER_LTE, "2.0.1", "2.0.0", false), + // Greater than + Arguments.of(ConditionOperator.SEMVER_GT, "1.0.1", "1.0.0", true), + Arguments.of(ConditionOperator.SEMVER_GT, "1.0.0", "1.0.0", false), + // Greater than or equal + Arguments.of(ConditionOperator.SEMVER_GTE, "1.0.0", "1.0.0", true), + Arguments.of(ConditionOperator.SEMVER_GTE, "0.9.9", "1.0.0", false), + // Prerelease ordering + Arguments.of(ConditionOperator.SEMVER_LT, "1.0.0-beta.1", "1.0.0", true), + Arguments.of(ConditionOperator.SEMVER_LT, "1.0.0-beta.2", "1.0.0-beta.11", true), + // Build metadata is ignored + Arguments.of(ConditionOperator.SEMVER_EQ, "4.0.0+build.42", "4.0.0", true), + Arguments.of(ConditionOperator.SEMVER_EQ, "4.0.0+exp.sha.5114f85", "4.0.0", true), + Arguments.of(ConditionOperator.SEMVER_NEQ, "4.0.0+build.42", "4.0.0", false), + Arguments.of(ConditionOperator.SEMVER_LT, "4.0.0+build.42", "4.0.0", false), + Arguments.of(ConditionOperator.SEMVER_LTE, "4.0.0+build.42", "4.0.0", true), + Arguments.of(ConditionOperator.SEMVER_GT, "4.0.0+build.42", "4.0.0", false), + Arguments.of(ConditionOperator.SEMVER_GTE, "4.0.0+build.42", "4.0.0", true), + Arguments.of(ConditionOperator.SEMVER_EQ, "1.0.0+linux", "1.0.0+darwin", true), + // Invalid attribute does not match + Arguments.of(ConditionOperator.SEMVER_NEQ, "not-a-version", "1.0.0", false), + Arguments.of(ConditionOperator.SEMVER_GTE, "1.2", "1.0.0", true), + Arguments.of(ConditionOperator.SEMVER_GTE, "v1.2.3", "1.0.0", false), + Arguments.of(ConditionOperator.SEMVER_GTE, "18446744073709551616.0.0", "1.0.0", false), + // Non-string attribute does not match + Arguments.of(ConditionOperator.SEMVER_EQ, 1.2, "1.2.0", false), + }; + } + + @ParameterizedTest(name = "{0} attr={1} comparand={2} -> {3}") + @MethodSource("semverConditionTestCases") + public void testEvaluateSemverCondition( + final ConditionOperator operator, + final Object attribute, + final String comparand, + final boolean wantMatch) { + final Map flags = new HashMap<>(); + flags.put("test-flag", semverFlag(operator, comparand)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", null, null, flags)); + + final ProviderEvaluation details = + evaluator.evaluate(Boolean.class, "test-flag", false, semverContext(attribute)); + + if (wantMatch) { + assertThat(details.getValue(), equalTo(true)); + assertThat(details.getReason(), equalTo("TARGETING_MATCH")); + } else { + assertThat(details.getValue(), equalTo(false)); + assertThat(details.getReason(), equalTo("DEFAULT")); + } + } + + @Test + public void testEvaluateSemverConditionMissingAttribute() { + final Map flags = new HashMap<>(); + flags.put("test-flag", semverFlag(ConditionOperator.SEMVER_EQ, "1.2.3")); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", null, null, flags)); + + final ProviderEvaluation details = + evaluator.evaluate(Boolean.class, "test-flag", false, semverContext(null)); + + assertThat(details.getValue(), equalTo(false)); + assertThat(details.getReason(), equalTo("DEFAULT")); + } + + @Test + public void testEvaluateSemverConditionInvalidComparandReturnsParseError() { + // A flag with an invalid semver comparand is dropped during parsing. + // The evaluator should return PARSE_ERROR when the flag is queried. + final Map flags = new HashMap<>(); + final Map invalidFlags = new HashMap<>(); + invalidFlags.put("invalid-semver", "invalid_semver_comparand"); + final ServerConfiguration config = new ServerConfiguration("", "", null, null, flags); + config.invalidFlags = invalidFlags; + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + evaluator.accept(config); + + final ProviderEvaluation details = + evaluator.evaluate(Boolean.class, "invalid-semver", false, semverContext("1.2.3")); + + assertThat(details.getValue(), equalTo(false)); + assertThat(details.getReason(), equalTo(ERROR.name())); + assertThat(details.getErrorCode(), equalTo(ErrorCode.PARSE_ERROR)); + } + + @Test + public void testEvaluateMalformedFlagReturnsParseError() { + final Map flags = new HashMap<>(); + final Map invalidFlags = new HashMap<>(); + invalidFlags.put("malformed-flag", "invalid_flag"); + final ServerConfiguration config = new ServerConfiguration("", "", null, null, flags); + config.invalidFlags = invalidFlags; + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + evaluator.accept(config); + + final ProviderEvaluation details = + evaluator.evaluate(Boolean.class, "malformed-flag", false, new MutableContext()); + + assertThat(details.getValue(), equalTo(false)); + assertThat(details.getReason(), equalTo(ERROR.name())); + assertThat(details.getErrorCode(), equalTo(ErrorCode.PARSE_ERROR)); + } + + private static Arguments[] flatteningTestCases() { + final List arguments = new ArrayList<>(); + arguments.add(Arguments.of(emptyMap(), emptyMap())); + arguments.add( + Arguments.of( + mapOf("integer", 1, "double", 23D, "boolean", true, "string", "string", "null", null), + mapOf("integer", 1, "double", 23D, "boolean", true, "string", "string", "null", null))); + arguments.add( + Arguments.of( + mapOf("list", asList(1, 2, singletonList(4))), + mapOf("list[0]", 1, "list[1]", 2, "list[2][0]", 4))); + arguments.add( + Arguments.of( + mapOf("map", mapOf("key1", 1, "key2", 2, "key3", mapOf("key4", 4))), + mapOf("map.key1", 1, "map.key2", 2, "map.key3.key4", 4))); + arguments.add( + Arguments.of( + mapOf("plan", "gold", "cohort", "gold"), mapOf("plan", "gold", "cohort", "gold"))); + final Instant instant = Instant.parse("2026-07-10T12:34:56Z"); + arguments.add(Arguments.of(mapOf("instant", instant), mapOf("instant", instant.toString()))); + return arguments.toArray(new Arguments[0]); + } + + @MethodSource("flatteningTestCases") + @ParameterizedTest + public void testFlattening( + final Map attributes, final Map expected) { + final EvaluationContext context = + new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); + final Map result = DDEvaluator.flattenContext(context); + + assertThat(result.size(), equalTo(expected.size())); + for (final Map.Entry entry : expected.entrySet()) { + assertThat(result, hasEntry(entry.getKey(), entry.getValue())); + } + } + + @Test + public void testDeeplyNestedContextIsTruncatedRatherThanOverflowingTheStack() { + Value nested = new Value("leaf"); + for (int i = 0; i < 10_000; i++) { + nested = new Value(singletonList(nested)); + } + final EvaluationContext context = new MutableContext().add("deep", singletonList(nested)); + + final Map result = DDEvaluator.flattenContext(context); + + final StringBuilder truncatedKey = new StringBuilder("deep"); + for (int i = 0; i < DDEvaluator.MAX_SNAPSHOT_DEPTH; i++) { + truncatedKey.append("[0]"); + } + assertThat(result.size(), equalTo(1)); + assertThat(result, hasEntry(truncatedKey.toString(), null)); + } + + @Test + public void testCopyPrunedContextCapsTopLevelFieldCount() { + final MutableContext context = new MutableContext(); + for (int i = 0; i < DDEvaluator.MAX_CONTEXT_FIELDS + 100; i++) { + context.add(String.format("k%04d", i), "v"); + } + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + assertThat(result.attrs.size(), equalTo(DDEvaluator.MAX_CONTEXT_FIELDS)); + assertThat(result.truncatedReason, equalTo("max_context_fields")); + } + + @Test + public void testCopyPrunedContextSkipsOversizedStringValues() { + final char[] longChars = new char[DDEvaluator.MAX_VALUE_LENGTH + 1]; + java.util.Arrays.fill(longChars, 'x'); + final MutableContext context = new MutableContext(); + context.add("keep", "ok"); + context.add("drop", new String(longChars)); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + assertThat(result.attrs, hasEntry("keep", "ok")); + assertThat(result.attrs.containsKey("drop"), equalTo(false)); + assertThat(result.truncatedReason, equalTo("max_value_length")); + } + + @Test + public void testCopyPrunedContextSkipsOversizedKeys() { + final char[] longKeyChars = new char[DDEvaluator.MAX_KEY_LENGTH + 1]; + java.util.Arrays.fill(longKeyChars, 'k'); + final MutableContext context = new MutableContext(); + context.add("keep", "ok"); + context.add(new String(longKeyChars), "drop"); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + assertThat(result.attrs, hasEntry("keep", "ok")); + assertThat(result.attrs.size(), equalTo(1)); + assertThat(result.truncatedReason, equalTo("max_key_length")); + } + + @Test + public void testCopyPrunedContextCapsListWidth() { + final List wide = new java.util.ArrayList<>(); + for (int i = 0; i < DDEvaluator.MAX_LIST_ELEMENTS + 50; i++) { + wide.add(Value.objectToValue("v" + i)); + } + final EvaluationContext context = new MutableContext().add("list", wide); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + assertThat(result.attrs.containsKey("list[0]"), equalTo(true)); + assertThat( + result.attrs.containsKey("list[" + (DDEvaluator.MAX_LIST_ELEMENTS - 1) + "]"), + equalTo(true)); + assertThat( + result.attrs.containsKey("list[" + DDEvaluator.MAX_LIST_ELEMENTS + "]"), equalTo(false)); + assertThat(result.truncatedReason, equalTo("max_list_elements")); + } + + @Test + public void testCopyPrunedContextCapsStructureWidth() { + final dev.openfeature.sdk.MutableStructure wide = new dev.openfeature.sdk.MutableStructure(); + for (int i = 0; i < DDEvaluator.MAX_STRUCTURE_PROPERTIES + 50; i++) { + wide.add(String.format("p%04d", i), "v"); + } + final EvaluationContext context = new MutableContext().add("struct", wide); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + long structKeys = result.attrs.keySet().stream().filter(k -> k.startsWith("struct.")).count(); + assertThat(structKeys, equalTo((long) DDEvaluator.MAX_STRUCTURE_PROPERTIES)); + assertThat(result.truncatedReason, equalTo("max_structure_properties")); + } + + @Test + public void testCopyPrunedContextTruncatesDeepNesting() { + Value nested = new Value("leaf"); + for (int i = 0; i < 10_000; i++) { + nested = new Value(singletonList(nested)); + } + final EvaluationContext context = new MutableContext().add("deep", singletonList(nested)); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + final StringBuilder truncatedKey = new StringBuilder("deep"); + for (int i = 0; i < DDEvaluator.MAX_SNAPSHOT_DEPTH; i++) { + truncatedKey.append("[0]"); + } + // The recursion stops on the first list element at MAX_SNAPSHOT_DEPTH; deeper elements are + // never walked and no entry is emitted for them. + assertThat(result.attrs.containsKey(truncatedKey.toString()), equalTo(false)); + assertThat(result.attrs.size(), equalTo(0)); + assertThat(result.truncatedReason, equalTo("max_snapshot_depth")); + } + + @Test + public void testCopyPrunedContextExcludesTargetingKey() { + final MutableContext context = new MutableContext("user-42").add("region", "us-east-1"); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + assertThat(result.attrs, hasEntry("region", "us-east-1")); + assertThat(result.attrs.containsKey("targetingKey"), equalTo(false)); + assertThat(result.truncatedReason, equalTo(null)); + } + + @Test + public void testCopyPrunedContextNoTruncationReturnsNullReason() { + final MutableContext context = new MutableContext("user-1"); + context.add("region", "us-east-1"); + context.add("tier", "gold"); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + assertThat(result.attrs, hasEntry("region", "us-east-1")); + assertThat(result.truncatedReason, equalTo(null)); + } + + @Test + public void testCopyPrunedContextMultipleReasonsAreSortedAndDeduplicated() { + final char[] longChars = new char[DDEvaluator.MAX_VALUE_LENGTH + 1]; + java.util.Arrays.fill(longChars, 'x'); + final char[] longKeyChars = new char[DDEvaluator.MAX_KEY_LENGTH + 1]; + java.util.Arrays.fill(longKeyChars, 'k'); + final MutableContext context = new MutableContext(); + context.add("keep", "ok"); + context.add("dropValue", new String(longChars)); + context.add(new String(longKeyChars), "dropKey"); + + final DDEvaluator.CopyResult result = DDEvaluator.copyPrunedContext(context); + + // Both max_key_length and max_value_length fired; sorted alphabetically, no duplicates. + assertThat(result.truncatedReason, equalTo("max_key_length,max_value_length")); + } + + private static Arguments[] typeCompatibilityTestCases() { + return new Arguments[] { + Arguments.of(Boolean.class, ValueType.BOOLEAN, true), + Arguments.of(String.class, ValueType.BOOLEAN, false), + Arguments.of(String.class, ValueType.STRING, true), + Arguments.of(Boolean.class, ValueType.STRING, false), + Arguments.of(Integer.class, ValueType.INTEGER, true), + Arguments.of(String.class, ValueType.INTEGER, false), + Arguments.of(Double.class, ValueType.NUMERIC, true), + Arguments.of(String.class, ValueType.NUMERIC, false), + Arguments.of(Value.class, ValueType.JSON, true), + Arguments.of(String.class, ValueType.JSON, false), + Arguments.of(String.class, null, true), + }; + } + + @ParameterizedTest + @MethodSource("typeCompatibilityTestCases") + public void testTypeCompatibility( + final Class target, final ValueType variationType, final boolean expected) { + assertThat(DDEvaluator.isTypeCompatible(target, variationType), equalTo(expected)); + } + + @Test + public void testContextCopyDetectsContainerCycles() { + final List cyclicList = new ArrayList<>(); + final Value listValue = new Value(cyclicList); + cyclicList.add(listValue); + final EvaluationContext listContext = mock(EvaluationContext.class); + when(listContext.keySet()).thenReturn(java.util.Collections.singleton("list")); + when(listContext.getValue("list")).thenReturn(listValue); + + final DDEvaluator.CopyResult listResult = DDEvaluator.copyPrunedContext(listContext); + assertThat(listResult.truncatedReason, equalTo("cycle")); + + final dev.openfeature.sdk.MutableStructure cyclicStructure = + new dev.openfeature.sdk.MutableStructure(); + final Value structureValue = new Value(cyclicStructure); + cyclicStructure.add("self", structureValue); + final EvaluationContext structureContext = mock(EvaluationContext.class); + when(structureContext.keySet()).thenReturn(java.util.Collections.singleton("structure")); + when(structureContext.getValue("structure")).thenReturn(structureValue); + + final DDEvaluator.CopyResult structureResult = DDEvaluator.copyPrunedContext(structureContext); + assertThat(structureResult.truncatedReason, equalTo("cycle")); + } + + @Test + public void testCopyPrunedContextHandlesEmptyAndScalarValues() { + assertThat(DDEvaluator.copyPrunedContext(null).attrs, equalTo(emptyMap())); + assertThat(DDEvaluator.copyPrunedContext(new MutableContext()).attrs, equalTo(emptyMap())); + + final EvaluationContext nullContext = mock(EvaluationContext.class); + when(nullContext.keySet()) + .thenReturn(new java.util.LinkedHashSet<>(asList("java-null", "openfeature-null"))); + when(nullContext.getValue("java-null")).thenReturn(null); + when(nullContext.getValue("openfeature-null")).thenReturn(new Value()); + final Map snapshot = DDEvaluator.snapshotValues(nullContext); + assertNull(snapshot.get("java-null")); + assertThat(snapshot.get("openfeature-null").isNull(), equalTo(true)); + final DDEvaluator.CopyResult nullResult = DDEvaluator.copyPrunedContext(nullContext); + assertThat(nullResult.attrs, hasEntry("java-null", null)); + assertThat(nullResult.attrs, hasEntry("openfeature-null", null)); + + final MutableContext scalarContext = new MutableContext(); + scalarContext.add("boolean", true); + scalarContext.add("number", 42); + scalarContext.add("instant", Instant.parse("2026-08-20T00:00:00Z")); + final DDEvaluator.CopyResult scalarResult = DDEvaluator.copyPrunedContext(scalarContext); + assertThat(scalarResult.attrs, hasEntry("boolean", true)); + assertThat(scalarResult.attrs, hasEntry("number", 42)); + assertThat(scalarResult.attrs, hasEntry("instant", "2026-08-20T00:00:00Z")); + } + + @Test + public void testFlattenValuesHandlesJavaNullAndContainerCycles() { + final Map values = new HashMap<>(); + values.put("null", null); + + final List cyclicList = new ArrayList<>(); + final Value listValue = new Value(cyclicList); + cyclicList.add(listValue); + values.put("list", listValue); + + final dev.openfeature.sdk.MutableStructure cyclicStructure = + new dev.openfeature.sdk.MutableStructure(); + final Value structureValue = new Value(cyclicStructure); + cyclicStructure.add("self", structureValue); + values.put("structure", structureValue); + + final Map flattened = DDEvaluator.flattenValues(values); + assertThat(flattened, hasEntry("null", null)); + assertThat(flattened.size(), equalTo(1)); + } + + private static Map mapOf(final Object... props) { + final Map result = new HashMap<>(props.length << 1); + int index = 0; + while (index < props.length) { + final String key = String.valueOf(props[index++]); + final Object value = props[index++]; + result.put(key, value); + } + return result; + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java new file mode 100644 index 00000000000..32eb936c932 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java @@ -0,0 +1,648 @@ +package com.datadog.openfeature; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.FlagValueType; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.MutableStructure; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link FlagEvalLoggingHook}: context capture, non-blocking enqueue, eval-time + * metadata, absent-variant detection, and killswitch-via-writer-null behaviour. + */ +class FlagEvalLoggingHookTest { + + // ---- helpers ---- + + /** Creates a pipeline mock that captures the enqueued event for assertion. */ + private static FlagEvaluationWriter capturingWriter(final AtomicReference ref) { + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + when(writer.hasCapacityForEnqueue()).thenReturn(true); + doAnswer( + invocation -> { + ref.set(invocation.getArgument(0)); + return null; + }) + .when(writer) + .enqueue(any()); + return writer; + } + + private static FlagEvalLoggingHook hookWithWriter(final FlagEvaluationWriter writer) { + return new FlagEvalLoggingHook<>(() -> writer); + } + + private static FlagEvaluationDetails details( + final String flagKey, + final Object value, + final String variant, + final String reason, + final ImmutableMetadata metadata) { + final FlagEvaluationDetails.FlagEvaluationDetailsBuilder builder = + FlagEvaluationDetails.builder().flagKey(flagKey).value(value).reason(reason); + if (variant != null) { + builder.variant(variant); + } + if (metadata != null) { + builder.flagMetadata(metadata); + } + return builder.build(); + } + + private static HookContext hookCtxWithTargetingKey( + final String flagKey, final String targetingKey) { + final MutableContext ctx = new MutableContext(targetingKey); + return HookContext.builder() + .flagKey(flagKey) + .type(FlagValueType.STRING) + .defaultValue("default") + .ctx(ctx) + .build(); + } + + // ---- test: hook calls writer.enqueue once with flagKey, variant, allocationKey ---- + + @Test + void finallyAfterEnqueuesEventWithAllBasicFields() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final FlagEvaluationDetails det = + details( + "my-flag", + "on-value", + "on", + Reason.TARGETING_MATCH.name(), + ImmutableMetadata.builder().addString("allocationKey", "alloc-1").build()); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get(), "writer.enqueue must be called once"); + final FlagEvalEvent e = captured.get(); + assertEquals("my-flag", e.flagKey); + assertEquals("on", e.variant, "variant must be the OpenFeature variant key"); + assertEquals("alloc-1", e.allocationKey); + } + + // ---- variant comes from details.getVariant(), NOT details.getValue() ---- + + @Test + void variantIsTheVariantKeyNotTheEvaluatedValue() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + // value and variant DIFFER, so a value-vs-variant mistake is detectable. + final FlagEvaluationDetails det = + details( + "g1-flag", + "the-evaluated-value", // value + "the-variant-key", // variant + Reason.TARGETING_MATCH.name(), + null); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertEquals( + "the-variant-key", + captured.get().variant, + "variant must be sourced from details.getVariant(), not details.getValue()"); + } + + // ---- test: evalTimeMs from metadata "__dd_eval_timestamp_ms" ---- + + @Test + void evalTimeMsComesFromMetadataWhenPresent() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final long expectedTimestamp = 1_700_000_000_000L; + final FlagEvaluationDetails det = + details( + "ts-flag", + "v", + "v", + Reason.SPLIT.name(), + ImmutableMetadata.builder() + .addString("allocationKey", "a") + .addLong("__dd_eval_timestamp_ms", expectedTimestamp) + .build()); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertEquals( + expectedTimestamp, + captured.get().evalTimeMs, + "evalTimeMs must come from __dd_eval_timestamp_ms metadata when present"); + } + + // ---- test: evalTimeMs falls back to System.currentTimeMillis() when absent ---- + + @Test + void evalTimeMsFallsBackToCurrentTimeWhenMetadataAbsent() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final long before = System.currentTimeMillis(); + final FlagEvaluationDetails det = + details("ts-flag", "v", "v", Reason.SPLIT.name(), null); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + final long after = System.currentTimeMillis(); + assertNotNull(captured.get()); + final long ts = captured.get().evalTimeMs; + assertTrue( + ts >= before && ts <= after, + "evalTimeMs must fall back to hook-fire time when metadata absent. got: " + ts); + } + + // ---- test: absent variant -> variant is null -> runtime default ---- + + @Test + void absentVariantProducesNullVariant() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + // A runtime default returns the default value but no variant. + final FlagEvaluationDetails det = + details("def-flag", "default-value", null, Reason.DEFAULT.name(), null); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertNull(captured.get().variant, "Absent variant must stay null (runtime default)"); + } + + // ---- test: error message captured from details (error object support) ---- + + @Test + void errorMessageCapturedFromDetailsUnderConsentOn() { + // With observeFullEvaluationData=true the provider's raw message is preserved verbatim so + // operators keep the diagnostic detail they opted in to. + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final FlagEvaluationDetails det = + FlagEvaluationDetails.builder() + .flagKey("err-flag") + .value("default") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.TYPE_MISMATCH) + .errorMessage("value does not match declared type") + .flagMetadata(consentOnMetadata()) + .build(); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertEquals( + "value does not match declared type", + captured.get().errorMessage, + "errorMessage must be captured from the evaluation details under consent-on"); + } + + @Test + void errorMessageReplacedByErrorCodeUnderConsentOff() { + // Defense-in-depth: even if a provider hands us a raw message under consent-off (a bug in the + // provider, or a third-party provider that doesn't distinguish consent tiers), the hook must + // substitute the ErrorCode name so raw PII from exception messages never reaches the wire. + // Uses a PII-looking marker exactly like the wire-level guards in FlagEvaluationWriterImplTest. + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final FlagEvaluationDetails det = + FlagEvaluationDetails.builder() + .flagKey("err-flag") + .value("default") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.TYPE_MISMATCH) + .errorMessage("For input string: \"jane.doe@datadoghq.com\"") + .flagMetadata(consentOffMetadata()) + .build(); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertEquals( + "TYPE_MISMATCH", + captured.get().errorMessage, + "consent-off must replace the raw message with the ErrorCode name"); + assertFalse( + captured.get().errorMessage.contains("jane.doe@datadoghq.com"), + "raw PII must never survive into the enqueued event under consent-off"); + } + + @Test + void errorMessageDroppedWhenConsentOffAndNoErrorCode() { + // Edge case: no ErrorCode available (unusual — providers should set one for ERROR reason). + // Consent-off drops the message and there's nothing to substitute, so the enqueued event has + // no error message at all. This is the strictest privacy-preserving outcome. + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final FlagEvaluationDetails det = + FlagEvaluationDetails.builder() + .flagKey("err-flag") + .value("default") + .reason(Reason.ERROR.name()) + .errorMessage("For input string: \"jane.doe@datadoghq.com\"") + .flagMetadata(consentOffMetadata()) + .build(); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertNull(captured.get().errorMessage); + } + + // ---- test: error code used as fallback message when error message is empty ---- + + @Test + void errorCodeUsedAsFallbackWhenMessageEmpty() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final FlagEvaluationDetails det = + FlagEvaluationDetails.builder() + .flagKey("err-flag") + .value("default") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .build(); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertEquals( + "FLAG_NOT_FOUND", + captured.get().errorMessage, + "error code name must be used when no error message is present"); + } + + // ---- test: success path has no error message ---- + + @Test + void successPathHasNullErrorMessage() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final FlagEvaluationDetails det = + details("ok-flag", "v", "v", Reason.TARGETING_MATCH.name(), null); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertNull(captured.get().errorMessage, "success path must have no error message"); + } + + // ---- test: hook does NO aggregation on the hook thread ---- + + @Test + void finallyAfterOnlyCallsEnqueueAndCapacityCheckOnWriter() { + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + when(writer.hasCapacityForEnqueue()).thenReturn(true); + final FlagEvalLoggingHook hook = hookWithWriter(writer); + + final FlagEvaluationDetails det = + details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + // Capacity check gates the enqueue; exactly one enqueue call; nothing else. + verify(writer, times(1)).hasCapacityForEnqueue(); + verify(writer, times(1)).enqueue(any(FlagEvalEvent.class)); + verifyNoMoreInteractions(writer); + } + + @Test + void finallyAfterSkipsEnqueueAndCountsDropWhenQueueSaturated() { + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + when(writer.hasCapacityForEnqueue()).thenReturn(false); + final FlagEvalLoggingHook hook = hookWithWriter(writer); + + final FlagEvaluationDetails det = + details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); + + hook.finallyAfter(null, det, Collections.emptyMap()); + + // Pre-queue guard fires: one capacity check, one drop count, and no enqueue at all. + verify(writer, times(1)).hasCapacityForEnqueue(); + verify(writer, times(1)).countPreQueueOverflow(); + verify(writer, never()).enqueue(any(FlagEvalEvent.class)); + } + + @Test + void countContextTruncatedCalledWhenContextIsTruncated() { + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + when(writer.hasCapacityForEnqueue()).thenReturn(true); + final FlagEvalLoggingHook hook = hookWithWriter(writer); + + // Context with one oversized string value — triggers max_value_length. + final char[] longChars = new char[DDEvaluator.MAX_VALUE_LENGTH + 1]; + java.util.Arrays.fill(longChars, 'x'); + final MutableContext ctx = new MutableContext("user-1"); + ctx.add("oversized", new String(longChars)); + final HookContext hookCtx = + HookContext.builder() + .flagKey("flag") + .type(dev.openfeature.sdk.FlagValueType.STRING) + .defaultValue("default") + .ctx(ctx) + .build(); + final FlagEvaluationDetails det = + details( + "flag", + "v", + "v", + dev.openfeature.sdk.Reason.TARGETING_MATCH.name(), + consentOnMetadata()); + + hook.finallyAfter(hookCtx, det, Collections.emptyMap()); + + verify(writer, times(1)).countContextTruncated("max_value_length"); + verify(writer, times(1)).enqueue(any(FlagEvalEvent.class)); + } + + @Test + void countContextTruncatedNotCalledWhenNoTruncation() { + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + when(writer.hasCapacityForEnqueue()).thenReturn(true); + final FlagEvalLoggingHook hook = hookWithWriter(writer); + + final MutableContext ctx = new MutableContext("user-1"); + ctx.add("region", "us-east-1"); + final HookContext hookCtx = + HookContext.builder() + .flagKey("flag") + .type(dev.openfeature.sdk.FlagValueType.STRING) + .defaultValue("default") + .ctx(ctx) + .build(); + final FlagEvaluationDetails det = + details( + "flag", + "v", + "v", + dev.openfeature.sdk.Reason.TARGETING_MATCH.name(), + consentOnMetadata()); + + hook.finallyAfter(hookCtx, det, Collections.emptyMap()); + + verify(writer, never()).countContextTruncated(any()); + verify(writer, times(1)).enqueue(any(FlagEvalEvent.class)); + } + + // ---- test: writer=null -> no-op (killswitch off / not yet started) ---- + + @Test + void writerNullIsNoOp() { + final FlagEvalLoggingHook hook = hookWithWriter(null); + final FlagEvaluationDetails det = + details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); + + // Must not throw; nothing is enqueued + hook.finallyAfter(null, det, Collections.emptyMap()); + } + + @Test + void writerLookupLinkageErrorIsNoOp() { + final FlagEvalLoggingHook hook = + new FlagEvalLoggingHook<>( + () -> { + throw new NoSuchMethodError("old bootstrap"); + }); + final FlagEvaluationDetails det = + details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); + + assertDoesNotThrow(() -> hook.finallyAfter(null, det, Collections.emptyMap())); + } + + // ---- test: details=null -> no-op ---- + + @Test + void detailsNullIsNoOp() { + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + final FlagEvalLoggingHook hook = hookWithWriter(writer); + + // Should not throw + hook.finallyAfter(null, null, Collections.emptyMap()); + + verifyNoInteractions(writer); + } + + // ---- test: targetingKey extracted from evaluation context ---- + + @Test + void targetingKeyExtractedFromContext() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final FlagEvaluationDetails det = + details("ctx-flag", "v", "v", Reason.SPLIT.name(), null); + + final HookContext hookCtx = hookCtxWithTargetingKey("ctx-flag", "user-42"); + + hook.finallyAfter(hookCtx, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + assertEquals( + "user-42", + captured.get().targetingKey, + "targetingKey must be extracted from the evaluation context"); + } + + @Test + void contextAttributesAreFlattenedAndConvertedInline() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final Map profile = new HashMap<>(); + profile.put("tier", "gold"); + final Map attributes = new HashMap<>(); + attributes.put("score", 42); + attributes.put("profile", profile); + final MutableContext context = + new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); + context.setTargetingKey("user-42"); + + final HookContext hookCtx = + HookContext.builder() + .flagKey("ctx-flag") + .type(FlagValueType.STRING) + .defaultValue("default") + .ctx(context) + .build(); + final FlagEvaluationDetails det = + details("ctx-flag", "v", "v", Reason.TARGETING_MATCH.name(), consentOnMetadata()); + + hook.finallyAfter(hookCtx, det, Collections.emptyMap()); + + assertNotNull(captured.get()); + // Flatten now happens inline in DDEvaluator#copyPrunedContext on the hot path, so the event + // arrives with attrs already populated (no deferred supplier). + final Map attrs = captured.get().attrs; + assertFalse(attrs.isEmpty(), "hook must flatten context inline"); + assertEquals(42, attrs.get("score")); + assertEquals("gold", attrs.get("profile.tier")); + assertFalse(attrs.containsKey("targetingKey")); + assertTrue( + attrs.values().stream().noneMatch(Value.class::isInstance), + "context attrs must contain converted scalar values, not OpenFeature Value wrappers"); + } + + @Test + void contextAttributesUseEnqueueTimeSnapshot() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final MutableContext context = new MutableContext("user-42"); + context.add("region", "us-east-1"); + final MutableStructure profile = new MutableStructure(); + profile.add("tier", "gold"); + context.add("profile", profile); + final List cohorts = new ArrayList<>(); + cohorts.add(Value.objectToValue("beta")); + context.add("cohorts", cohorts); + + final HookContext hookCtx = + HookContext.builder() + .flagKey("ctx-flag") + .type(FlagValueType.STRING) + .defaultValue("default") + .ctx(context) + .build(); + final FlagEvaluationDetails det = + details("ctx-flag", "v", "v", Reason.TARGETING_MATCH.name(), consentOnMetadata()); + + hook.finallyAfter(hookCtx, det, Collections.emptyMap()); + context.add("region", "eu-west-1"); + context.add("late", "ignored"); + profile.add("tier", "platinum"); + profile.add("late", "ignored"); + cohorts.set(0, Value.objectToValue("ga")); + cohorts.add(Value.objectToValue("late")); + + assertNotNull(captured.get()); + final Map attrs = captured.get().attrs; + assertEquals("us-east-1", attrs.get("region")); + assertEquals("gold", attrs.get("profile.tier")); + assertEquals("beta", attrs.get("cohorts[0]")); + assertFalse(attrs.containsKey("late")); + assertFalse(attrs.containsKey("profile.late")); + assertFalse(attrs.containsKey("cohorts[1]")); + } + + // ---- observeFullEvaluationData is read from evaluation metadata, never the gateway ---- + + @Test + void readsObserveFullEvaluationDataTrueFromEvaluationMetadata() { + assertTrue(enqueuedEventWithConsentMetadata(true).observeFullEvaluationData); + } + + @Test + void readsObserveFullEvaluationDataFalseFromEvaluationMetadata() { + assertFalse(enqueuedEventWithConsentMetadata(false).observeFullEvaluationData); + } + + @Test + void observeFullEvaluationDataDefaultsToFalseWhenMetadataAbsent() { + // No metadata at all: fail-closed toward privacy. + assertFalse(enqueuedEventWithConsentMetadata(null).observeFullEvaluationData); + } + + @Test + void protectedPathSkipsEvaluationContextCapture() { + // Consent off → the hook must not snapshot the evaluation context at all. Verified by mutating + // the context after finallyAfter returns and asserting the enqueued event still sees nothing. + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + final MutableContext context = new MutableContext("user-1"); + context.add("region", "us-east-1"); + + final HookContext hookCtx = + HookContext.builder() + .flagKey("ctx-flag") + .type(FlagValueType.STRING) + .defaultValue("default") + .ctx(context) + .build(); + final ImmutableMetadata consentOff = + ImmutableMetadata.builder() + .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, false) + .build(); + + hook.finallyAfter( + hookCtx, + details("ctx-flag", "v", "v", Reason.TARGETING_MATCH.name(), consentOff), + Collections.emptyMap()); + context.add("region", "eu-west-1"); + + assertNotNull(captured.get()); + assertTrue(captured.get().attrs.isEmpty()); + } + + /** + * Fires the hook once for a simple targeted evaluation whose metadata carries the given consent + * value ({@code null} = key absent) and returns the enqueued event. + */ + private FlagEvalEvent enqueuedEventWithConsentMetadata(final Boolean consent) { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + final ImmutableMetadata metadata = + consent == null + ? null + : ImmutableMetadata.builder() + .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, consent) + .build(); + hook.finallyAfter( + hookCtxWithTargetingKey("obs-flag", "user-1"), + details("obs-flag", "on", "on", Reason.TARGETING_MATCH.name(), metadata), + Collections.emptyMap()); + assertNotNull(captured.get(), "writer.enqueue must be called once"); + return captured.get(); + } + + private static ImmutableMetadata consentOnMetadata() { + return ImmutableMetadata.builder() + .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, true) + .build(); + } + + private static ImmutableMetadata consentOffMetadata() { + return ImmutableMetadata.builder() + .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, false) + .build(); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsHookTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsHookTest.java new file mode 100644 index 00000000000..cb56826fbb0 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsHookTest.java @@ -0,0 +1,126 @@ +package com.datadog.openfeature; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.Reason; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +class FlagEvalMetricsHookTest { + + @Test + void finallyAfterRecordsBasicEvaluation() { + FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); + FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); + + FlagEvaluationDetails details = + FlagEvaluationDetails.builder() + .flagKey("my-flag") + .value("on-value") + .variant("on") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata( + ImmutableMetadata.builder().addString("allocationKey", "default-alloc").build()) + .build(); + + hook.finallyAfter(null, details, Collections.emptyMap()); + + verify(metrics) + .record( + eq("my-flag"), + eq("on"), + eq(Reason.TARGETING_MATCH.name()), + isNull(), + eq("default-alloc")); + } + + @Test + void finallyAfterRecordsErrorEvaluation() { + FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); + FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); + + FlagEvaluationDetails details = + FlagEvaluationDetails.builder() + .flagKey("missing-flag") + .value("default") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .build(); + + hook.finallyAfter(null, details, Collections.emptyMap()); + + verify(metrics) + .record( + eq("missing-flag"), + isNull(), + eq(Reason.ERROR.name()), + eq(ErrorCode.FLAG_NOT_FOUND), + isNull()); + } + + @Test + void finallyAfterHandlesNullFlagMetadata() { + FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); + FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); + + FlagEvaluationDetails details = + FlagEvaluationDetails.builder() + .flagKey("my-flag") + .value(true) + .variant("on") + .reason(Reason.TARGETING_MATCH.name()) + .build(); + + hook.finallyAfter(null, details, Collections.emptyMap()); + + verify(metrics) + .record(eq("my-flag"), eq("on"), eq(Reason.TARGETING_MATCH.name()), isNull(), isNull()); + } + + @Test + void finallyAfterHandlesNullVariantAndReason() { + FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); + FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); + + FlagEvaluationDetails details = + FlagEvaluationDetails.builder().flagKey("my-flag").value("default").build(); + + hook.finallyAfter(null, details, Collections.emptyMap()); + + verify(metrics).record(eq("my-flag"), isNull(), isNull(), isNull(), isNull()); + } + + @Test + void finallyAfterNeverThrows() { + FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); + FlagEvalMetricsHook hook = new FlagEvalMetricsHook(metrics); + + // Should not throw even with completely null inputs + hook.finallyAfter(null, null, null); + + verifyNoInteractions(metrics); + } + + @Test + void finallyAfterIsNoOpWhenMetricsIsNull() { + FlagEvalMetricsHook hook = new FlagEvalMetricsHook(null); + + FlagEvaluationDetails details = + FlagEvaluationDetails.builder() + .flagKey("my-flag") + .value(true) + .variant("on") + .reason(Reason.TARGETING_MATCH.name()) + .build(); + + // Should not throw + hook.finallyAfter(null, details, Collections.emptyMap()); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsLateSdkForkedTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsLateSdkForkedTest.java new file mode 100644 index 00000000000..80d212a10f8 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsLateSdkForkedTest.java @@ -0,0 +1,52 @@ +package com.datadog.openfeature; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.LongCounterBuilder; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.metrics.MeterBuilder; +import io.opentelemetry.api.metrics.MeterProvider; +import org.junit.jupiter.api.Test; + +class FlagEvalMetricsLateSdkForkedTest { + @Test + void evaluationBeforeSdkRegistrationDoesNotClaimTheGlobalProvider() { + GlobalOpenTelemetry.resetForTest(); + final FlagEvalMetrics metrics = new FlagEvalMetrics(); + metrics.record("before", "on", "STATIC", null, null); + assertFalse(GlobalOpenTelemetry.isSet()); + + final OpenTelemetry telemetry = mock(OpenTelemetry.class); + final MeterProvider provider = mock(MeterProvider.class); + final MeterBuilder meterBuilder = mock(MeterBuilder.class); + final Meter meter = mock(Meter.class); + final LongCounterBuilder counterBuilder = mock(LongCounterBuilder.class); + final LongCounter counter = mock(LongCounter.class); + when(telemetry.getMeterProvider()).thenReturn(provider); + when(provider.meterBuilder("ddtrace.openfeature")).thenReturn(meterBuilder); + when(meterBuilder.build()).thenReturn(meter); + when(meter.counterBuilder("feature_flag.evaluations")).thenReturn(counterBuilder); + when(counterBuilder.setUnit("{evaluation}")).thenReturn(counterBuilder); + when(counterBuilder.setDescription("Number of feature flag evaluations")) + .thenReturn(counterBuilder); + when(counterBuilder.build()).thenReturn(counter); + try { + assertDoesNotThrow(() -> GlobalOpenTelemetry.set(telemetry)); + metrics.record("after", "on", "STATIC", null, null); + verify(counter).add(eq(1L), any(Attributes.class)); + } finally { + metrics.close(); + GlobalOpenTelemetry.resetForTest(); + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsTest.java new file mode 100644 index 00000000000..61e0ab4dd4b --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsTest.java @@ -0,0 +1,165 @@ +package com.datadog.openfeature; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import dev.openfeature.sdk.ErrorCode; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.LongCounter; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class FlagEvalMetricsTest { + + @Test + void recordBasicAttributes() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("my-flag", "on", "TARGETING_MATCH", null, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "feature_flag.key", "my-flag"); + assertAttribute(attrs, "feature_flag.result.variant", "on"); + assertAttribute(attrs, "feature_flag.result.reason", "targeting_match"); + assertNoAttribute(attrs, "error.type"); + assertNoAttribute(attrs, "feature_flag.result.allocation_key"); + } + + @Test + void recordErrorAttributes() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("missing-flag", "", "ERROR", ErrorCode.FLAG_NOT_FOUND, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "feature_flag.key", "missing-flag"); + assertAttribute(attrs, "feature_flag.result.variant", ""); + assertAttribute(attrs, "feature_flag.result.reason", "error"); + assertAttribute(attrs, "error.type", "flag_not_found"); + assertNoAttribute(attrs, "feature_flag.result.allocation_key"); + } + + @Test + void recordTypeMismatchError() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("my-flag", "", "ERROR", ErrorCode.TYPE_MISMATCH, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "error.type", "type_mismatch"); + } + + @Test + void recordWithAllocationKey() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("my-flag", "on", "TARGETING_MATCH", null, "default-allocation"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "feature_flag.result.allocation_key", "default-allocation"); + } + + @Test + void recordOmitsEmptyAllocationKey() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("my-flag", "on", "TARGETING_MATCH", null, ""); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertNoAttribute(attrs, "feature_flag.result.allocation_key"); + } + + @Test + void recordNullVariantBecomesEmptyString() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("my-flag", null, "DEFAULT", null, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "feature_flag.result.variant", ""); + } + + @Test + void recordNullReasonBecomesUnknown() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("my-flag", "on", null, null, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "feature_flag.result.reason", "unknown"); + } + + @Test + void recordIsNoOpWhenCounterIsNull() { + FlagEvalMetrics metrics = new FlagEvalMetrics((LongCounter) null); + // Should not throw + metrics.record("my-flag", "on", "TARGETING_MATCH", null, null); + } + + @Test + void shutdownClearsCounter() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.shutdown(); + metrics.record("my-flag", "on", "TARGETING_MATCH", null, null); + + verifyNoInteractions(counter); + } + + @Test + void defaultConstructorUsesOpenTelemetryApiOnly() { + try (FlagEvalMetrics metrics = new FlagEvalMetrics()) { + metrics.record("count-flag", "on", "STATIC", null, "default-alloc"); + } + } + + private static void assertAttribute(Attributes attrs, String key, String expected) { + String value = + attrs.asMap().entrySet().stream() + .filter(e -> e.getKey().getKey().equals(key)) + .map(e -> e.getValue().toString()) + .findFirst() + .orElse(null); + if (!expected.equals(value)) { + throw new AssertionError("Expected attribute " + key + "=" + expected + " but got " + value); + } + } + + private static void assertNoAttribute(Attributes attrs, String key) { + boolean present = attrs.asMap().keySet().stream().anyMatch(k -> k.getKey().equals(key)); + if (present) { + throw new AssertionError("Expected no attribute " + key + " but it was present"); + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java new file mode 100644 index 00000000000..4008aac4974 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java @@ -0,0 +1,600 @@ +package com.datadog.openfeature; + +import static com.datadog.openfeature.Provider.METADATA; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.datadog.openfeature.Provider.Options; +import com.datadog.openfeature.internal.JsonReading; +import com.datadog.openfeature.internal.LocalHttpServer; +import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.connector.ConfigurationSource; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import com.datadog.openfeature.internal.connector.SpanEnricher; +import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.Flag; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.Split; +import com.datadog.openfeature.internal.ufc.ValueType; +import com.datadog.openfeature.internal.ufc.Variant; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.EventDetails; +import dev.openfeature.sdk.Features; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.ProviderEvent; +import dev.openfeature.sdk.ProviderState; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.FatalError; +import dev.openfeature.sdk.exceptions.ProviderNotReadyError; +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public class ProviderTest { + + private static final long EVENT_TIMEOUT_SECONDS = 10; + + private ExecutorService executor; + private LocalHttpServer cdn; + private DDEvaluator evaluator; + + @BeforeEach + public void setup() throws IOException { + this.executor = Executors.newSingleThreadExecutor(); + this.cdn = new LocalHttpServer(); + } + + @AfterEach + public void tearDown() { + this.executor.shutdownNow(); + OpenFeatureAPI.getInstance().shutdown(); + this.cdn.close(); + } + + /** + * Creates a provider backed by a real evaluator, whose runtime polls a local CDN that has no + * configuration. Configurations are then dispatched with {@link #dispatch}. + */ + private Provider newProvider(final Options options) { + return newProvider(options, Connector.NONE); + } + + private Provider newProvider(final Options options, final Connector connector) { + final Provider[] providerRef = new Provider[1]; + this.evaluator = + new DDEvaluator( + () -> providerRef[0].onConfigurationChange(), + connector, + TestSettings.of( + "feature.flags.configuration.source.agentless.base.url", + this.cdn.uri("/").toString())); + providerRef[0] = new Provider(options, this.evaluator); + return providerRef[0]; + } + + private Provider newProvider() { + return newProvider(new Options().initTimeout(30, SECONDS)); + } + + private void dispatch(final ServerConfiguration configuration) { + this.evaluator.accept(configuration); + } + + private static ServerConfiguration emptyConfiguration() { + return new ServerConfiguration("", "", false, null, emptyMap()); + } + + @Test + public void testSetProvider() throws Exception { + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + final CompletableFuture readyEvent = new CompletableFuture<>(); + api.onProviderReady(readyEvent::complete); + api.setProvider(newProvider()); + + final Client client = api.getClient(); + assertThat(client.getProviderState(), equalTo(ProviderState.NOT_READY)); + + dispatch(emptyConfiguration()); + readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); + assertThat(client.getProviderState(), equalTo(ProviderState.READY)); + } + + @Test + public void testSetProviderAndWait() throws Exception { + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + final Provider newProvider = newProvider(); + final Future provider = this.executor.submit(() -> api.setProviderAndWait(newProvider)); + + final Client client = api.getClient(); + assertThat(client.getProviderState(), equalTo(ProviderState.NOT_READY)); + + dispatch(emptyConfiguration()); + provider.get(EVENT_TIMEOUT_SECONDS, SECONDS); + assertThat(client.getProviderState(), equalTo(ProviderState.READY)); + } + + @Test + public void testSetProviderAndWaitTimeoutRecoversWhenConfigurationArrives() throws Exception { + final CompletableFuture readyEvent = new CompletableFuture<>(); + final Consumer readyEventHandler = completingHandler(readyEvent); + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + final Client client = api.getClient(); + client.on(ProviderEvent.PROVIDER_READY, readyEventHandler); + + assertThrows( + ProviderNotReadyError.class, + () -> api.setProviderAndWait(newProvider(new Options().initTimeout(10, MILLISECONDS)))); + + assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); + assertFalse(readyEvent.isDone()); + + dispatch(emptyConfiguration()); + + final EventDetails eventDetails = readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); + assertThat(client.getProviderState(), equalTo(ProviderState.READY)); + assertThat(eventDetails.getProviderName(), equalTo(METADATA)); + verify(readyEventHandler, times(1)).accept(any()); + } + + @Test + public void testSetProviderAndWaitCompletesWhenConfigurationArrivesAtTimeoutBoundary() + throws Exception { + final Provider[] providerRef = new Provider[1]; + final Evaluator evaluator = + new Evaluator() { + private boolean hasConfiguration; + + @Override + public boolean initialize( + final long timeout, + final java.util.concurrent.TimeUnit timeUnit, + final EvaluationContext context) { + hasConfiguration = true; + providerRef[0].onConfigurationChange(); + return false; + } + + @Override + public boolean hasConfiguration() { + return hasConfiguration; + } + + @Override + public void shutdown() {} + + @Override + public ProviderEvaluation evaluate( + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context) { + return ProviderEvaluation.builder().value(defaultValue).build(); + } + }; + + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator); + api.setProviderAndWait(providerRef[0]); + + final Client client = api.getClient(); + assertThat(client.getProviderState(), equalTo(ProviderState.READY)); + } + + @Test + public void testSetProviderAndWaitFailsWhenConfigurationIsRemovedBeforeInitializationCompletes() { + final Provider[] providerRef = new Provider[1]; + final Evaluator evaluator = + new Evaluator() { + private boolean hasConfiguration; + + @Override + public boolean initialize( + final long timeout, + final java.util.concurrent.TimeUnit timeUnit, + final EvaluationContext context) { + hasConfiguration = true; + providerRef[0].onConfigurationChange(); + hasConfiguration = false; + providerRef[0].onConfigurationChange(); + return true; + } + + @Override + public boolean hasConfiguration() { + return hasConfiguration; + } + + @Override + public void shutdown() {} + + @Override + public ProviderEvaluation evaluate( + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context) { + return ProviderEvaluation.builder().value(defaultValue).build(); + } + }; + + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator); + + assertThrows(ProviderNotReadyError.class, () -> api.setProviderAndWait(providerRef[0])); + + final Client client = api.getClient(); + assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); + } + + @Test + public void testInitializationErrorDoesNotOverwriteRecoveredReadyState() throws Exception { + final Provider[] providerRef = new Provider[1]; + final Evaluator evaluator = + new Evaluator() { + private boolean hasConfiguration; + + @Override + public boolean initialize( + final long timeout, + final java.util.concurrent.TimeUnit timeUnit, + final EvaluationContext context) { + hasConfiguration = true; + providerRef[0].onConfigurationChange(); + hasConfiguration = false; + providerRef[0].onConfigurationChange(); + hasConfiguration = true; + providerRef[0].onConfigurationChange(); + throw new ProviderNotReadyError( + "Provider timed-out while waiting for initial configuration"); + } + + @Override + public boolean hasConfiguration() { + return hasConfiguration; + } + + @Override + public void shutdown() {} + + @Override + public ProviderEvaluation evaluate( + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context) { + return ProviderEvaluation.builder().value(defaultValue).build(); + } + }; + + providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator); + + assertThrows(ProviderNotReadyError.class, () -> providerRef[0].initialize(null)); + + assertThat(initializationState(providerRef[0]), equalTo("READY")); + } + + @Test + public void testNullConfigurationAfterReadyTransitionsToErrorAndRecovers() throws Exception { + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + final Provider provider = newProvider(); + this.executor.submit(() -> api.setProviderAndWait(provider)); + awaitInitialization(); + dispatch(emptyConfiguration()); + awaitReady(api); + final Client client = api.getClient(); + assertThat(client.getProviderState(), equalTo(ProviderState.READY)); + + final CompletableFuture errorEvent = new CompletableFuture<>(); + final CompletableFuture readyEvent = new CompletableFuture<>(); + final CompletableFuture configChangedEvent = new CompletableFuture<>(); + final Consumer errorEventHandler = completingHandler(errorEvent); + final Consumer readyEventHandler = completingHandler(readyEvent); + final Consumer configChangedEventHandler = completingHandler(configChangedEvent); + client.on(ProviderEvent.PROVIDER_ERROR, errorEventHandler); + client.on(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, configChangedEventHandler); + + dispatch(null); + final EventDetails eventDetails = errorEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); + assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); + assertThat(eventDetails.getProviderName(), equalTo(METADATA)); + + final FlagEvaluationDetails evalDetails = client.getStringDetails("missing", "default"); + assertThat(evalDetails.getValue(), equalTo("default")); + assertThat(evalDetails.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); + + client.on(ProviderEvent.PROVIDER_READY, readyEventHandler); + dispatch(emptyConfiguration()); + readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); + assertThat(client.getProviderState(), equalTo(ProviderState.READY)); + + dispatch(emptyConfiguration()); + configChangedEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); + verify(errorEventHandler, times(1)).accept(any()); + verify(readyEventHandler, times(1)).accept(any()); + verify(configChangedEventHandler, times(1)).accept(any()); + } + + @Test + public void testDisabledProductFailsInitialization() { + final Provider[] providerRef = new Provider[1]; + final DDEvaluator disabled = + new DDEvaluator( + () -> providerRef[0].onConfigurationChange(), + Connector.NONE, + TestSettings.of("feature.flags.enabled", "false")); + providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), disabled); + + final FatalError error = + assertThrows( + FatalError.class, + () -> OpenFeatureAPI.getInstance().setProviderAndWait(providerRef[0])); + assertThat(error.getCause().getMessage(), equalTo("Feature Flags is disabled")); + } + + @Test + public void testRemoteConfigurationWithoutAgentFailsInitialization() { + final Provider[] providerRef = new Provider[1]; + final DDEvaluator remoteConfig = + new DDEvaluator( + () -> providerRef[0].onConfigurationChange(), + Connector.NONE, + TestSettings.of("feature.flags.configuration.source", "remote_config")); + providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), remoteConfig); + + assertThrows( + FatalError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(providerRef[0])); + } + + @Test + public void testGetProviderHooksReturnsFlagEvalMetricsHook() { + Provider provider = + new Provider(new Options().initTimeout(10, MILLISECONDS), mock(Evaluator.class)); + List hooks = provider.getProviderHooks(); + // Two hooks: OTel FlagEvalMetricsHook (index 0) + FlagEvalLoggingHook (index 1) + assertThat(hooks.size(), equalTo(2)); + assertThat(hooks.get(0) instanceof FlagEvalMetricsHook, equalTo(true)); + assertThat(hooks.get(1) instanceof FlagEvalLoggingHook, equalTo(true)); + } + + @Test + public void testClientEvaluationRoutesThroughFlagEvalLoggingHook() throws Exception { + final BlockingQueue posted = new LinkedBlockingQueue<>(); + final Provider provider = + newProvider( + new Options().initTimeout(10, SECONDS), + new ProxyConnector( + (route, json) -> { + if ("flagevaluation".equals(route)) { + posted.add(json); + } + })); + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + this.executor.submit(() -> api.setProviderAndWait(provider)); + awaitInitialization(); + dispatch(loggedFlagConfiguration()); + awaitReady(api); + final MutableContext context = new MutableContext("user-1"); + context.add("region", "us-east-1"); + + final FlagEvaluationDetails details = + api.getClient().getStringDetails("logged-flag", "default", context); + // Shutting down the provider flushes the evaluation pipeline. + api.shutdown(); + + assertThat(details.getValue(), equalTo("value")); + final byte[] payload = posted.poll(EVENT_TIMEOUT_SECONDS, SECONDS); + assertTrue(payload != null, "flag evaluation payload must be posted on shutdown"); + final Map event = firstFlagEvaluation(payload); + assertThat(((Map) event.get("flag")).get("key"), equalTo("logged-flag")); + assertThat(((Map) event.get("variant")).get("key"), equalTo("variant-1")); + assertThat(((Map) event.get("allocation")).get("key"), equalTo("allocation-1")); + assertThat(event.get("targeting_key"), equalTo("user-1")); + assertThat(event.get("evaluation_count"), equalTo(1L)); + assertThat( + ((Map) ((Map) event.get("context")).get("evaluation")).get("region"), + equalTo("us-east-1")); + } + + @Test + public void testGetProviderHooksReturnsFlagEvalMetricsHookWithAndWithoutSpanEnrichment() { + final Evaluator evaluator = mock(Evaluator.class); + final Provider providerWithoutSpanEnrichment = + new Provider(new Options(), evaluator, Boolean.FALSE); + final Provider providerWithSpanEnrichment = + new Provider(new Options(), evaluator, Boolean.TRUE); + + assertHasFlagEvalMetricsHook(providerWithoutSpanEnrichment); + assertHasFlagEvalMetricsHook(providerWithSpanEnrichment); + } + + @Test + public void testShutdownCleansUpEvaluator() throws Exception { + final Evaluator evaluator = mock(Evaluator.class); + when(evaluator.initialize(eq(10L), eq(MILLISECONDS), any())).thenReturn(true); + when(evaluator.hasConfiguration()).thenReturn(true); + final Provider provider = + new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator, Boolean.FALSE); + + provider.initialize(null); + provider.shutdown(); + + verify(evaluator).shutdown(); + // After shutdown, getProviderHooks still returns a list with both OTel + logging hooks + assertThat(provider.getProviderHooks().size(), equalTo(2)); + } + + private static void assertHasFlagEvalMetricsHook(final Provider provider) { + assertTrue( + provider.getProviderHooks().stream().anyMatch(FlagEvalMetricsHook.class::isInstance), + "flag evaluation metrics hook should be registered"); + } + + public interface EvaluateMethod { + FlagEvaluationDetails evaluate(Features client, String flag, E defaultValue); + } + + private static Arguments[] providerMethods() { + return new Arguments[] { + Arguments.of("bool", false, (EvaluateMethod) Features::getBooleanDetails), + Arguments.of("string", "Hello!", (EvaluateMethod) Features::getStringDetails), + Arguments.of("int", 23, (EvaluateMethod) Features::getIntegerDetails), + Arguments.of("double", 3.14D, (EvaluateMethod) Features::getDoubleDetails), + Arguments.of("object", new Value(), (EvaluateMethod) Features::getObjectDetails) + }; + } + + @MethodSource("providerMethods") + @ParameterizedTest + public void testProviderEvaluation( + final String flag, final E defaultValue, final EvaluateMethod method) throws Exception { + final Evaluator evaluator = mock(Evaluator.class); + when(evaluator.initialize(eq(10L), eq(SECONDS), any())).thenReturn(true); + when(evaluator.hasConfiguration()).thenReturn(true); + when(evaluator.evaluate(any(), any(), any(), any())) + .thenAnswer( + invocation -> + ProviderEvaluation.builder() + .value(invocation.getArgument(2)) + .reason("MOCK") + .build()); + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setProviderAndWait(new Provider(new Options().initTimeout(10, SECONDS), evaluator)); + final Client client = api.getClient(); + final FlagEvaluationDetails result = method.evaluate(client, flag, defaultValue); + assertThat(result.getValue(), equalTo(defaultValue)); + assertThat(result.getReason(), equalTo("MOCK")); + verify(evaluator, times(1)).initialize(eq(10L), eq(SECONDS), any()); + verify(evaluator, times(1)) + .evaluate(any(), eq(flag), eq(defaultValue), any(EvaluationContext.class)); + } + + private static String initializationState(final Provider provider) throws Exception { + final Field stateField = Provider.class.getDeclaredField("initializationState"); + stateField.setAccessible(true); + final AtomicReference state = (AtomicReference) stateField.get(provider); + return state.get().toString(); + } + + @SuppressWarnings("unchecked") + private static Consumer completingHandler( + final CompletableFuture event) { + final Consumer handler = mock(Consumer.class); + doAnswer( + invocation -> { + event.complete(invocation.getArgument(0)); + return null; + }) + .when(handler) + .accept(any()); + return handler; + } + + /** Waits for the evaluator to start waiting for its initial configuration. */ + private void awaitInitialization() throws InterruptedException { + final long deadline = System.nanoTime() + SECONDS.toNanos(EVENT_TIMEOUT_SECONDS); + while (this.evaluator.runtime() == null && System.nanoTime() < deadline) { + Thread.sleep(5); + } + assertTrue(this.evaluator.runtime() != null, "provider initialization must start"); + } + + private static void awaitReady(final OpenFeatureAPI api) throws InterruptedException { + final long deadline = System.nanoTime() + SECONDS.toNanos(EVENT_TIMEOUT_SECONDS); + while (api.getClient().getProviderState() != ProviderState.READY + && System.nanoTime() < deadline) { + Thread.sleep(5); + } + assertThat(api.getClient().getProviderState(), equalTo(ProviderState.READY)); + } + + private static ServerConfiguration loggedFlagConfiguration() { + final Map variations = new HashMap<>(); + variations.put("variant-1", new Variant("variant-1", "value")); + final Split split = new Split(java.util.Collections.emptyList(), "variant-1", emptyMap(), null); + final Allocation allocation = + new Allocation("allocation-1", null, null, null, singletonList(split), Boolean.TRUE); + final Map flags = new HashMap<>(); + flags.put( + "logged-flag", + new Flag("logged-flag", true, ValueType.STRING, variations, singletonList(allocation))); + return new ServerConfiguration("", "", true, null, flags); + } + + @SuppressWarnings("unchecked") + private static Map firstFlagEvaluation(final byte[] payload) { + final Map request = JsonReading.readObject(payload); + return (Map) ((List) request.get("flagEvaluations")).get(0); + } + + /** A connector only providing an event platform proxy. */ + private static final class ProxyConnector implements Connector { + private final EventTransport proxy; + + ProxyConnector(final EventTransport proxy) { + this.proxy = proxy; + } + + @Override + public String setting(final String key) { + return null; + } + + @Override + public ConfigurationSource remoteConfiguration() { + return null; + } + + @Override + public EventTransport eventProxy() { + return this.proxy; + } + + @Override + public HealthMetrics healthMetrics() { + return HealthMetrics.NOOP; + } + + @Override + public SpanEnricher spanEnricher() { + return SpanEnricher.NOOP; + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/SpanEnrichmentHookTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/SpanEnrichmentHookTest.java new file mode 100644 index 00000000000..e9f261e9ad3 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/SpanEnrichmentHookTest.java @@ -0,0 +1,289 @@ +package com.datadog.openfeature; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.datadog.openfeature.internal.connector.SpanEnricher; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.FlagValueType; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.ImmutableStructure; +import dev.openfeature.sdk.Value; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Capture-side unit suite for APM feature-flag span enrichment. The hook records evaluations to the + * {@link SpanEnricher} and the agent does the accumulation, so these tests assert the recorded + * events (not span tags) plus the {@link Provider} gating. + */ +class SpanEnrichmentHookTest { + + private final List captured = new ArrayList<>(); + private final SpanEnricher enricher = + new SpanEnricher() { + @Override + public void serialId(final int serialId, final boolean doLog, final String targetingKey) { + SpanEnrichmentHookTest.this.captured.add( + new SpanEnrichmentEvent(true, serialId, doLog, targetingKey, null, null)); + } + + @Override + public void runtimeDefault(final String flagKey, final Object defaultValue) { + SpanEnrichmentHookTest.this.captured.add( + new SpanEnrichmentEvent(false, 0, false, null, flagKey, defaultValue)); + } + }; + + // ---- helpers ---- + + private static FlagEvaluationDetails details( + final String flagKey, + final String variant, + final Object value, + final ImmutableMetadata metadata) { + return FlagEvaluationDetails.builder() + .flagKey(flagKey) + .variant(variant) + .value(value) + .flagMetadata(metadata) + .build(); + } + + private static ImmutableMetadata metadata(final Integer serialId, final boolean doLog) { + final ImmutableMetadata.ImmutableMetadataBuilder builder = ImmutableMetadata.builder(); + if (serialId != null) { + builder.addInteger(SpanEnrichmentHook.METADATA_SERIAL_ID, serialId); + } + builder.addBoolean(SpanEnrichmentHook.METADATA_DO_LOG, doLog); + return builder.build(); + } + + private static HookContext ctx(final String flagKey, final String targetingKey) { + return HookContext.from( + flagKey, + FlagValueType.STRING, + null, + null, + targetingKey == null ? new ImmutableContext() : new ImmutableContext(targetingKey), + "default"); + } + + // ---- serial-id branch ---- + + @Test + void serialIdWithDoLogDispatchesSerialAndSubject() { + new SpanEnrichmentHook(this.enricher) + .finallyAfter( + ctx("flag", "user-1"), + details("flag", "on", "v", metadata(42, true)), + Collections.emptyMap()); + + assertEquals(1, captured.size()); + final SpanEnrichmentEvent event = captured.get(0); + assertTrue(event.hasSerialId()); + assertEquals(42, event.serialId()); + assertTrue(event.doLog()); + assertEquals("user-1", event.targetingKey()); + } + + @Test + void serialIdWithoutDoLogStillDispatchesSerialButNotDoLog() { + new SpanEnrichmentHook(this.enricher) + .finallyAfter( + ctx("flag", "user-1"), + details("flag", "on", "v", metadata(7, false)), + Collections.emptyMap()); + + assertEquals(1, captured.size()); + final SpanEnrichmentEvent event = captured.get(0); + assertTrue(event.hasSerialId()); + assertEquals(7, event.serialId()); + assertFalse( + event.doLog(), "doLog=false must be carried through so the write side skips subject"); + } + + @Test + void wrongTypedSerialIdDispatchesNothing() { + // Defensive: a non-integer value under the serial-id key (wrong type) is ignored, not crashed. + final ImmutableMetadata bad = + ImmutableMetadata.builder() + .addString(SpanEnrichmentHook.METADATA_SERIAL_ID, "not-a-number") + .addBoolean(SpanEnrichmentHook.METADATA_DO_LOG, true) + .build(); + new SpanEnrichmentHook(this.enricher) + .finallyAfter( + ctx("flag", "user-1"), details("flag", "on", "v", bad), Collections.emptyMap()); + assertTrue(captured.isEmpty(), "a wrong-typed serial id must never break eval or dispatch"); + } + + // ---- runtime-default branch (missing variant) ---- + + @Test + void missingVariantDispatchesRuntimeDefaultWithNativeMap() { + final Map objectValue = Collections.singletonMap("k", "val"); + new SpanEnrichmentHook(this.enricher) + .finallyAfter( + ctx("obj-flag", "user-1"), + details("obj-flag", null, objectValue, ImmutableMetadata.builder().build()), + Collections.emptyMap()); + + assertEquals(1, captured.size()); + final SpanEnrichmentEvent event = captured.get(0); + assertFalse(event.hasSerialId()); + assertEquals("obj-flag", event.flagKey()); + assertEquals( + objectValue, event.defaultValue(), "a native map default passes through unchanged"); + } + + @Test + void missingVariantUnwrapsOpenFeatureValueStructureToNativeMap() { + final Map inner = new LinkedHashMap<>(); + inner.put("enabled", new Value(true)); + final Value structureDefault = new Value(new ImmutableStructure(inner)); + + new SpanEnrichmentHook(this.enricher) + .finallyAfter( + ctx("struct-flag", "user-1"), + details("struct-flag", null, structureDefault, ImmutableMetadata.builder().build()), + Collections.emptyMap()); + + assertEquals(1, captured.size()); + final Object value = captured.get(0).defaultValue(); + // Crucially a native Map, NOT an OpenFeature Value — the seam carries only JDK types. + final Map asMap = assertInstanceOf(Map.class, value); + assertEquals(Boolean.TRUE, asMap.get("enabled")); + } + + // ---- unwrapDefaultValue (the Value -> native conversion) ---- + + @Test + void unwrapConvertsValueScalarsToNative() { + assertEquals("hello", SpanEnrichmentHook.unwrapDefaultValue(new Value("hello"))); + assertEquals(Boolean.TRUE, SpanEnrichmentHook.unwrapDefaultValue(new Value(true))); + assertEquals(7, SpanEnrichmentHook.unwrapDefaultValue(new Value(7))); + assertNull(SpanEnrichmentHook.unwrapDefaultValue(new Value())); + } + + @Test + void unwrapConvertsValueListToNativeList() { + final Value listDefault = + new Value(Arrays.asList(new Value("a"), new Value(2), new Value(true))); + final Object out = SpanEnrichmentHook.unwrapDefaultValue(listDefault); + assertEquals(Arrays.asList("a", 2, Boolean.TRUE), out); + } + + @Test + void unwrapPassesNativeValuesThrough() { + final Map native0 = Collections.singletonMap("a", "b"); + assertEquals(native0, SpanEnrichmentHook.unwrapDefaultValue(native0)); + assertEquals("plain", SpanEnrichmentHook.unwrapDefaultValue("plain")); + assertNull(SpanEnrichmentHook.unwrapDefaultValue(null)); + } + + // ---- error isolation ---- + + @Test + void nullDetailsDispatchesNothing() { + new SpanEnrichmentHook(this.enricher).finallyAfter(null, null, null); + assertTrue(captured.isEmpty()); + } + + // ---- Provider gating ---- + + @Test + void gateOffConstructsNoHook() { + final Provider provider = new Provider(new Provider.Options(), null, Boolean.FALSE); + assertNull(provider.spanEnrichmentHook(), "gate off => no span-enrichment hook"); + for (final Hook hook : provider.getProviderHooks()) { + assertFalse( + hook instanceof SpanEnrichmentHook, "gate off => SpanEnrichmentHook never registered"); + } + } + + @Test + void gateOnConstructsHookAndRegistersItInProviderHooks() { + final Provider provider = new Provider(new Provider.Options(), null, Boolean.TRUE); + assertTrue(provider.spanEnrichmentHook() != null, "gate on => hook constructed"); + boolean registered = false; + for (final Hook hook : provider.getProviderHooks()) { + if (hook instanceof SpanEnrichmentHook) { + registered = true; + } + } + assertTrue(registered, "gate on => SpanEnrichmentHook registered in getProviderHooks"); + } + + @Test + void getProviderHooksReturnsSameInstanceEachCall() { + final Provider gateOff = new Provider(new Provider.Options(), null, Boolean.FALSE); + assertTrue( + gateOff.getProviderHooks() == gateOff.getProviderHooks(), + "gate off => getProviderHooks allocates nothing (same instance)"); + + final Provider gateOn = new Provider(new Provider.Options(), null, Boolean.TRUE); + assertTrue( + gateOn.getProviderHooks() == gateOn.getProviderHooks(), + "gate on => getProviderHooks allocates nothing (same instance)"); + } + + /** A recorded span enrichment. */ + private static final class SpanEnrichmentEvent { + private final boolean serialIdPresent; + private final int serialId; + private final boolean doLog; + private final String targetingKey; + private final String flagKey; + private final Object defaultValue; + + SpanEnrichmentEvent( + final boolean serialIdPresent, + final int serialId, + final boolean doLog, + final String targetingKey, + final String flagKey, + final Object defaultValue) { + this.serialIdPresent = serialIdPresent; + this.serialId = serialId; + this.doLog = doLog; + this.targetingKey = targetingKey; + this.flagKey = flagKey; + this.defaultValue = defaultValue; + } + + boolean hasSerialId() { + return this.serialIdPresent; + } + + int serialId() { + return this.serialId; + } + + boolean doLog() { + return this.doLog; + } + + String targetingKey() { + return this.targetingKey; + } + + String flagKey() { + return this.flagKey; + } + + Object defaultValue() { + return this.defaultValue; + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java new file mode 100644 index 00000000000..6c2284153a7 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java @@ -0,0 +1,261 @@ +package com.datadog.openfeature.internal; + +import static java.util.Collections.emptyMap; +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.junit.jupiter.api.Assertions.assertSame; +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.verify; + +import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.connector.ConfigurationSource; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import com.datadog.openfeature.internal.connector.SpanEnricher; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +class FeatureFlagsRuntimeTest { + private static final byte[] EMPTY_UFC = + "{\"environment\":{\"name\":\"Test\"},\"flags\":{}}".getBytes(StandardCharsets.UTF_8); + + @Test + void disabledProductIsRejected() { + final IllegalStateException error = + assertThrows( + IllegalStateException.class, + () -> + FeatureFlagsRuntime.create( + Connector.NONE, TestSettings.of("feature.flags.enabled", "false"))); + + assertEquals("Feature Flags is disabled", error.getMessage()); + } + + @Test + void remoteConfigurationRequiresTheJavaAgent() { + final IllegalStateException error = + assertThrows( + IllegalStateException.class, + () -> + FeatureFlagsRuntime.create( + Connector.NONE, + TestSettings.of("feature.flags.configuration.source", "remote_config"))); + + assertTrue(error.getMessage().contains("requires the Datadog Java agent")); + } + + @Test + void agentlessWithoutApiKeyOrProxyDisablesEventDelivery() { + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create(Connector.NONE, TestSettings.of()); + + assertNull(runtime.evaluations()); + } + + @Test + void evaluationCountsKillSwitchDisablesTheEvaluationPipeline() { + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create( + new TestConnector(null, (route, json) -> {}), + TestSettings.of("flagging.evaluation.counts.enabled", "false")); + + assertNull(runtime.evaluations()); + } + + @Test + void eventProxyEnablesBothPipelines() { + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create(new TestConnector(null, (route, json) -> {}), TestSettings.of()); + + assertNotNull(runtime.evaluations()); + } + + @Test + void remoteConfigurationDeliversParsedConfigurationsAndReplaysToLateListeners() { + final TestConfigurationSource source = new TestConfigurationSource(); + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create( + new TestConnector(source, null), + TestSettings.of("feature.flags.configuration.source", "remote_config")); + final List early = new CopyOnWriteArrayList<>(); + runtime.addConfigurationListener(early::add); + runtime.start(); + try { + source.listener.accept(EMPTY_UFC); + source.listener.accept("{not json".getBytes(StandardCharsets.UTF_8)); + final List late = new CopyOnWriteArrayList<>(); + runtime.addConfigurationListener(late::add); + + assertEquals(1, early.size()); + assertEquals("Test", early.get(0).environment.name); + assertEquals(1, late.size()); + assertSame(early.get(0), late.get(0)); + + source.listener.accept(null); + assertEquals(2, early.size()); + assertNull(early.get(1)); + } finally { + runtime.stop(); + } + assertTrue(source.closed); + } + + @Test + void removedListenersAreNotNotified() { + final TestConfigurationSource source = new TestConfigurationSource(); + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create( + new TestConnector(source, null), + TestSettings.of("feature.flags.configuration.source", "remote_config")); + @SuppressWarnings("unchecked") + final Consumer listener = mock(Consumer.class); + runtime.addConfigurationListener(listener); + runtime.removeConfigurationListener(listener); + runtime.start(); + try { + source.listener.accept(EMPTY_UFC); + } finally { + runtime.stop(); + } + + verify(listener, org.mockito.Mockito.never()).accept(org.mockito.ArgumentMatchers.any()); + } + + @Test + void failingListenerDoesNotStarveOtherListeners() { + final TestConfigurationSource source = new TestConfigurationSource(); + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create( + new TestConnector(source, null), + TestSettings.of("feature.flags.configuration.source", "remote_config")); + final List received = new CopyOnWriteArrayList<>(); + runtime.addConfigurationListener( + configuration -> { + throw new IllegalStateException("listener failure"); + }); + runtime.addConfigurationListener(received::add); + runtime.start(); + try { + source.listener.accept(EMPTY_UFC); + } finally { + runtime.stop(); + } + + assertEquals(1, received.size()); + } + + @Test + void sharedRuntimeIsReferenceCounted() { + final TestConfigurationSource source = new TestConfigurationSource(); + final Connector connector = new TestConnector(source, null); + final FeatureFlagsRuntime first = + FeatureFlagsRuntime.acquire( + connector, TestSettings.of("feature.flags.configuration.source", "remote_config")); + final FeatureFlagsRuntime second = FeatureFlagsRuntime.acquire(connector, TestSettings.of()); + try { + assertSame(first, second); + FeatureFlagsRuntime.release(); + assertTrue(!source.closed); + } finally { + FeatureFlagsRuntime.release(); + } + assertTrue(source.closed); + // Extra releases are ignored. + FeatureFlagsRuntime.release(); + } + + @Test + void failedStartDoesNotCacheTheSharedRuntime() { + final ConfigurationSource failing = + listener -> { + throw new IllegalStateException("subscription failed"); + }; + assertThrows( + IllegalStateException.class, + () -> + FeatureFlagsRuntime.acquire( + new TestConnector(failing, null), + TestSettings.of("feature.flags.configuration.source", "remote_config"))); + + final TestConfigurationSource source = new TestConfigurationSource(); + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.acquire( + new TestConnector(source, null), + TestSettings.of("feature.flags.configuration.source", "remote_config")); + try { + assertNotNull(runtime); + assertNotNull(source.listener, "a later acquisition starts a new runtime"); + } finally { + FeatureFlagsRuntime.release(); + } + } + + @Test + void recordsExposuresOnlyWhenDeliveryIsEnabled() { + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create(Connector.NONE, TestSettings.of()); + // Must not throw without an exposure pipeline. + runtime.recordExposure( + new com.datadog.openfeature.internal.exposure.ExposureEvent( + 1L, + new com.datadog.openfeature.internal.exposure.Allocation("a"), + new com.datadog.openfeature.internal.exposure.Flag("f"), + new com.datadog.openfeature.internal.exposure.Variant("v"), + new com.datadog.openfeature.internal.exposure.Subject("s", emptyMap()), + null)); + } + + private static final class TestConfigurationSource implements ConfigurationSource { + volatile Consumer listener; + volatile boolean closed; + + @Override + public AutoCloseable subscribe(final Consumer listener) { + this.listener = listener; + return () -> this.closed = true; + } + } + + private static final class TestConnector implements Connector { + private final ConfigurationSource remoteConfiguration; + private final EventTransport eventProxy; + + TestConnector(final ConfigurationSource remoteConfiguration, final EventTransport eventProxy) { + this.remoteConfiguration = remoteConfiguration; + this.eventProxy = eventProxy; + } + + @Override + public String setting(final String key) { + return null; + } + + @Override + public ConfigurationSource remoteConfiguration() { + return this.remoteConfiguration; + } + + @Override + public EventTransport eventProxy() { + return this.eventProxy; + } + + @Override + public HealthMetrics healthMetrics() { + return HealthMetrics.NOOP; + } + + @Override + public SpanEnricher spanEnricher() { + return SpanEnricher.NOOP; + } + } +}