From 78d88dfd4695807f68bae9f665160c131eceebf8 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 09:08:19 +0200 Subject: [PATCH 1/4] feat(ffe): Add dd-openfeature SDK OpenFeature provider Add the com.datadog.openfeature.Provider OpenFeature provider, its evaluator and hooks (metrics, logging, span enrichment), and the runtime wiring the configuration delivery and event pipelines together. The SDK is still not published, the agent-coupled provider remains the shipped one. Co-Authored-By: Claude Opus 5.5 --- dd-openfeature/build.gradle.kts | 9 +- dd-openfeature/gradle.lockfile | 3 + .../FlagEvalHookHotPathBenchmark.java | 188 ++++ .../com/datadog/openfeature/DDEvaluator.java | 572 ++++++++++ .../com/datadog/openfeature/Evaluator.java | 17 + .../openfeature/FlagEvalLoggingHook.java | 143 +++ .../datadog/openfeature/FlagEvalMetrics.java | 161 +++ .../openfeature/FlagEvalMetricsHook.java | 41 + .../com/datadog/openfeature/Provider.java | 335 ++++++ .../openfeature/SpanEnrichmentHook.java | 151 +++ .../internal/FeatureFlagsRuntime.java | 228 ++++ .../evaluation/ConfigurationStore.java | 32 + .../openfeature/CanonicalFixtureTest.java | 205 ++++ .../DDEvaluatorSpanEnrichmentTest.java | 67 ++ .../datadog/openfeature/DDEvaluatorTest.java | 984 ++++++++++++++++++ .../openfeature/FlagEvalLoggingHookTest.java | 648 ++++++++++++ .../openfeature/FlagEvalMetricsHookTest.java | 126 +++ .../FlagEvalMetricsLateSdkForkedTest.java | 52 + .../openfeature/FlagEvalMetricsTest.java | 165 +++ .../com/datadog/openfeature/ProviderTest.java | 600 +++++++++++ .../openfeature/SpanEnrichmentHookTest.java | 289 +++++ .../internal/FeatureFlagsRuntimeTest.java | 238 +++++ 22 files changed, 5250 insertions(+), 4 deletions(-) create mode 100644 dd-openfeature/src/jmh/java/com/datadog/openfeature/FlagEvalHookHotPathBenchmark.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/Evaluator.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetrics.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/ConfigurationStore.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorSpanEnrichmentTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsHookTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsLateSdkForkedTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/SpanEnrichmentHookTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java 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..08af9e7e571 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java @@ -0,0 +1,572 @@ +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.EvaluationResult; +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.ErrorCode; +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()); + } + + @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 + @SuppressWarnings("unchecked") + public ProviderEvaluation evaluate( + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context) { + final com.datadog.openfeature.internal.evaluation.EvaluationContext adapted = + context == null + ? null + : new com.datadog.openfeature.internal.evaluation.EvaluationContext() { + @Override + public String getTargetingKey() { + return context.getTargetingKey(); + } + + @Override + public boolean hasAttribute(final String name) { + return context.keySet().contains(name); + } + + @Override + public Object attribute(final String name) { + return context.convertValue(context.getValue(name)); + } + }; + final EvaluationResult result = + core.evaluate(configuration.get(), (Class) coreType(target), key, defaultValue, adapted); + final ImmutableMetadata.ImmutableMetadataBuilder metadata = ImmutableMetadata.builder(); + for (final Map.Entry entry : result.getFlagMetadata().values().entrySet()) { + final Object value = entry.getValue(); + if (value instanceof Boolean) metadata.addBoolean(entry.getKey(), (Boolean) value); + else if (value instanceof Integer) metadata.addInteger(entry.getKey(), (Integer) value); + else if (value instanceof Long) metadata.addLong(entry.getKey(), (Long) value); + else metadata.addString(entry.getKey(), (String) value); + } + final T value = + result.getValue() == defaultValue ? defaultValue : mapValue(target, result.getValue()); + final ProviderEvaluation evaluation = + ProviderEvaluation.builder() + .value(value) + .reason(result.getReason()) + .variant(result.getVariant()) + .errorCode( + result.getErrorCode() == null + ? null + : ErrorCode.valueOf(result.getErrorCode().name())) + .errorMessage(result.getErrorMessage()) + .flagMetadata(metadata.build()) + .build(); + if (result.isLogExposure()) { + dispatchExposure(key, evaluation, result.getSerialId(), context); + } + return evaluation; + } + + private static Class coreType(final Class target) { + return target == Value.class ? Object.class : target; + } + + 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(coreType(target), type); + } + + @SuppressWarnings("unchecked") + static T mapValue(final Class target, final Object value) { + if (target == Value.class) { + return value == null ? null : (T) Value.objectToValue(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..57896c89f7f --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java @@ -0,0 +1,228 @@ +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) { + listener.accept(configuration); + } + } + + /** + * 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..bc8545cd387 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java @@ -0,0 +1,238 @@ +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 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; + } + } +} From c38932f7c5d5f9aa7869f46cdaebd4e6eb870c0f Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 11:01:46 +0200 Subject: [PATCH 2/4] refactor(ffe): Pass evaluator metadata through to OpenFeature The evaluator now builds OpenFeature ImmutableMetadata directly, so the provider no longer copies it on every evaluation. Co-Authored-By: Claude Opus 5.5 --- .../main/java/com/datadog/openfeature/DDEvaluator.java | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java index 08af9e7e571..bf498809a27 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java @@ -182,14 +182,6 @@ public Object attribute(final String name) { }; final EvaluationResult result = core.evaluate(configuration.get(), (Class) coreType(target), key, defaultValue, adapted); - final ImmutableMetadata.ImmutableMetadataBuilder metadata = ImmutableMetadata.builder(); - for (final Map.Entry entry : result.getFlagMetadata().values().entrySet()) { - final Object value = entry.getValue(); - if (value instanceof Boolean) metadata.addBoolean(entry.getKey(), (Boolean) value); - else if (value instanceof Integer) metadata.addInteger(entry.getKey(), (Integer) value); - else if (value instanceof Long) metadata.addLong(entry.getKey(), (Long) value); - else metadata.addString(entry.getKey(), (String) value); - } final T value = result.getValue() == defaultValue ? defaultValue : mapValue(target, result.getValue()); final ProviderEvaluation evaluation = @@ -202,7 +194,7 @@ public Object attribute(final String name) { ? null : ErrorCode.valueOf(result.getErrorCode().name())) .errorMessage(result.getErrorMessage()) - .flagMetadata(metadata.build()) + .flagMetadata(result.getFlagMetadata()) .build(); if (result.isLogExposure()) { dispatchExposure(key, evaluation, result.getSerialId(), context); From 9267c870e5e9a58b09c5c8d80f4de6cb09b80c63 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 11:25:04 +0200 Subject: [PATCH 3/4] refactor(ffe): Delegate evaluation to the OpenFeature-typed evaluator The evaluator now takes the OpenFeature context and returns the ProviderEvaluation, so the provider no longer adapts the context and converts the result on every evaluation. Exposures are dispatched through the evaluator ExposureListener. Co-Authored-By: Claude Opus 5.5 --- .../com/datadog/openfeature/DDEvaluator.java | 61 ++----------------- 1 file changed, 6 insertions(+), 55 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java index bf498809a27..f1ae8d1b40a 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java @@ -6,14 +6,12 @@ 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.EvaluationResult; 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.ErrorCode; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.ImmutableMetadata; import dev.openfeature.sdk.ImmutableStructure; @@ -106,7 +104,7 @@ class DDEvaluator implements Evaluator, Consumer { 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.core = new FlagEvaluator(settings.isSpanEnrichmentEnabled(), this::dispatchExposure); } @Override @@ -155,55 +153,12 @@ public void accept(final ServerConfiguration config) { } @Override - @SuppressWarnings("unchecked") public ProviderEvaluation evaluate( final Class target, final String key, final T defaultValue, final EvaluationContext context) { - final com.datadog.openfeature.internal.evaluation.EvaluationContext adapted = - context == null - ? null - : new com.datadog.openfeature.internal.evaluation.EvaluationContext() { - @Override - public String getTargetingKey() { - return context.getTargetingKey(); - } - - @Override - public boolean hasAttribute(final String name) { - return context.keySet().contains(name); - } - - @Override - public Object attribute(final String name) { - return context.convertValue(context.getValue(name)); - } - }; - final EvaluationResult result = - core.evaluate(configuration.get(), (Class) coreType(target), key, defaultValue, adapted); - final T value = - result.getValue() == defaultValue ? defaultValue : mapValue(target, result.getValue()); - final ProviderEvaluation evaluation = - ProviderEvaluation.builder() - .value(value) - .reason(result.getReason()) - .variant(result.getVariant()) - .errorCode( - result.getErrorCode() == null - ? null - : ErrorCode.valueOf(result.getErrorCode().name())) - .errorMessage(result.getErrorMessage()) - .flagMetadata(result.getFlagMetadata()) - .build(); - if (result.isLogExposure()) { - dispatchExposure(key, evaluation, result.getSerialId(), context); - } - return evaluation; - } - - private static Class coreType(final Class target) { - return target == Value.class ? Object.class : target; + return core.evaluate(configuration.get(), target, key, defaultValue, context); } static boolean isAllocationActive(final Allocation allocation, final Instant now) { @@ -211,20 +166,16 @@ static boolean isAllocationActive(final Allocation allocation, final Instant now } static boolean isTypeCompatible(final Class target, final ValueType type) { - return FlagEvaluator.isTypeCompatible(coreType(target), type); + return FlagEvaluator.isTypeCompatible(target, type); } - @SuppressWarnings("unchecked") static T mapValue(final Class target, final Object value) { - if (target == Value.class) { - return value == null ? null : (T) Value.objectToValue(value); - } return FlagEvaluator.mapValue(target, value); } - private void dispatchExposure( + private void dispatchExposure( final String flag, - final ProviderEvaluation evaluation, + final ProviderEvaluation evaluation, final Integer serialId, final EvaluationContext context) { final String allocationKey = allocationKey(evaluation); @@ -255,7 +206,7 @@ void recordExposure(final ExposureEvent event) { } } - private static String allocationKey(final ProviderEvaluation resolution) { + private static String allocationKey(final ProviderEvaluation resolution) { final ImmutableMetadata meta = resolution.getFlagMetadata(); return meta == null ? null : meta.getString("allocationKey"); } From 7a3aacbc68a0e5d370f8a38d94994089485e49a5 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 16:09:29 +0200 Subject: [PATCH 4/4] fix(ffe): Isolate Feature Flags configuration listeners Keep notifying the other configuration listeners when one of them fails. Co-Authored-By: Claude Opus 5.5 --- .../internal/FeatureFlagsRuntime.java | 6 ++++- .../internal/FeatureFlagsRuntimeTest.java | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) 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 index 57896c89f7f..b787eb0488c 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java @@ -181,7 +181,11 @@ void stop() { private void onConfiguration(@Nullable final ServerConfiguration configuration) { this.configuration = configuration; for (final Consumer listener : this.listeners) { - listener.accept(configuration); + try { + listener.accept(configuration); + } catch (final RuntimeException e) { + LOGGER.warn("Feature Flags configuration listener failed", e); + } } } 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 index bc8545cd387..6c2284153a7 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java @@ -129,6 +129,29 @@ void removedListenersAreNotNotified() { 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();