From 427caa8a9c810058c4ebb32cfd9d6430c0b85b57 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 09:07:11 +0200 Subject: [PATCH] feat(ffe): Add dd-openfeature SDK exposure and flag evaluation pipelines Add the exposure and flag evaluation event pipelines: deduplication, aggregation, payload building and batching over the event transport. Co-Authored-By: Claude Opus 5.5 --- dd-openfeature/build.gradle.kts | 5 + ...gEvaluationEnqueueContentionBenchmark.java | 189 ++++++ .../FlagEvaluationHotPathBenchmark.java | 160 ++++++ .../internal/exposure/Allocation.java | 9 + .../internal/exposure/ExposureCache.java | 64 +++ .../internal/exposure/ExposureEvent.java | 26 + .../internal/exposure/ExposurePipeline.java | 164 ++++++ .../internal/exposure/ExposuresRequest.java | 53 ++ .../openfeature/internal/exposure/Flag.java | 9 + .../internal/exposure/LRUExposureCache.java | 41 ++ .../internal/exposure/Subject.java | 13 + .../internal/exposure/Variant.java | 9 + .../flagevaluation/FlagEvalEvent.java | 98 ++++ .../FlagEvaluationAggregator.java | 463 +++++++++++++++ .../FlagEvaluationPayloads.java | 338 +++++++++++ .../FlagEvaluationPipeline.java | 543 ++++++++++++++++++ .../flagevaluation/FlagEvaluationWriter.java | 33 ++ .../exposure/ExposurePipelineTest.java | 299 ++++++++++ .../exposure/ExposuresRequestTest.java | 62 ++ .../exposure/LRUExposureCacheTest.java | 308 ++++++++++ .../FlagEvaluationAggregatorTest.java | 424 ++++++++++++++ .../FlagEvaluationPayloadsTest.java | 430 ++++++++++++++ .../FlagEvaluationPipelineTest.java | 484 ++++++++++++++++ .../FlagEvaluationTestSupport.java | 226 ++++++++ 24 files changed, 4450 insertions(+) create mode 100644 dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationEnqueueContentionBenchmark.java create mode 100644 dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationHotPathBenchmark.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Allocation.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureCache.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureEvent.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposuresRequest.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Flag.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/LRUExposureCache.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Subject.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Variant.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEvent.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregator.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloads.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationWriter.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposuresRequestTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/LRUExposureCacheTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregatorTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloadsTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipelineTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationTestSupport.java diff --git a/dd-openfeature/build.gradle.kts b/dd-openfeature/build.gradle.kts index c218e9603d7..f583a0cc81d 100644 --- a/dd-openfeature/build.gradle.kts +++ b/dd-openfeature/build.gradle.kts @@ -6,6 +6,7 @@ plugins { idea id("dd-trace-java.conventions.java") id("dd-trace-java.version-file") + id("dd-trace-java.jmh-conventions") } description = "Datadog OpenFeature provider SDK." @@ -37,3 +38,7 @@ dependencies { tasks.withType().configureEach { configureCompiler(JavaVersion.VERSION_11) } + +jmh { + jmhVersion = libs.versions.jmh.get() +} diff --git a/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationEnqueueContentionBenchmark.java b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationEnqueueContentionBenchmark.java new file mode 100644 index 00000000000..b862c1388f9 --- /dev/null +++ b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationEnqueueContentionBenchmark.java @@ -0,0 +1,189 @@ +package com.datadog.openfeature.internal.flagevaluation; + +import static java.util.concurrent.TimeUnit.NANOSECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; + +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import de.thetaphi.forbiddenapis.SuppressForbidden; +import java.util.HashMap; +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.Group; +import org.openjdk.jmh.annotations.GroupThreads; +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.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; + +/** + * Producer-contention benchmark for {@link FlagEvaluationPipeline#enqueue}. + * + *

Every flag evaluation in every application thread calls {@code enqueue}, so producer-side + * scaling is the property that matters. {@link FlagEvaluationHotPathBenchmark#writerEnqueue} + * measures the single-threaded cost and cannot see contention; this benchmark measures how enqueue + * behaves as producer count grows. + * + *

Why groups rather than {@code @Threads}: the hand-off queue ({@code + * EventQueue}) is multi-producer, single-consumer. Calling {@code poll()} from several + * threads breaks that contract, so producer threads cannot drain their own events. Each group + * therefore pairs N producers with exactly one consumer. + * + *

Why the consumer drains in batches: with more producers than consumers the + * queue would otherwise saturate, after which {@code offer} fast-fails and the measurement turns + * into {@code AtomicLong} contention on the overflow counter instead of the enqueue path. The + * consumer polls up to {@link #DRAIN_BATCH} events per invocation to keep the queue off its + * capacity limit. Overflow is reported at the end of each iteration so a saturated - and therefore + * invalid - run is visible rather than silent. + * + *

Run: {@code ./gradlew :dd-openfeature:jmh + * -Pjmh.includes=FlagEvaluationEnqueueContentionBenchmark}. + */ +@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 FlagEvaluationEnqueueContentionBenchmark { + + /** Events the single consumer drains per invocation, so it can keep up with N producers. */ + static final int DRAIN_BATCH = 32; + + private static final int NUM_FLAGS = 100; + private static final int NUM_USERS = 50; + private static final int NUM_FIELDS = 10; + + private Map attrs; + private String[] flagKeys; + private String[] targetingKeys; + private FlagEvaluationPipeline writer; + + /** Per-producer cursor: a shared counter would add its own cache-line contention. */ + @State(Scope.Thread) + public static class ProducerCursor { + int cursor; + } + + @Setup(Level.Iteration) + public void setUp() { + attrs = new HashMap<>(); + for (int i = 0; i < NUM_FIELDS; i++) { + attrs.put("field" + i, "value"); + } + flagKeys = keys("bench-flag-", NUM_FLAGS); + targetingKeys = keys("bench-user-", NUM_USERS); + + // Capacity well above what the batch-draining consumer should ever let build up. + writer = + new FlagEvaluationPipeline( + 1 << 20, + Long.MAX_VALUE, + NANOSECONDS, + (route, json) -> {}, + new HashMap<>(), + new RuntimeServices(HealthMetrics.NOOP)); + } + + /** + * Reports queue overflow so a saturated run - where the consumer failed to keep up and the + * numbers no longer describe the enqueue path - is visible in the benchmark output. + */ + @org.openjdk.jmh.annotations.TearDown(Level.Iteration) + @SuppressForbidden + public void reportOverflow() { + final long dropped = writer.droppedQueueOverflow(); + if (dropped > 0) { + System.out.println( + "\nWARNING: queue overflowed " + + dropped + + " times - consumer could not keep up, enqueue timings for this iteration are" + + " measuring overflow accounting, not the enqueue path."); + } + } + + // ---- 1 producer: uncontended baseline ---- + + @Benchmark + @Group("producers1") + @GroupThreads(1) + public void enqueue1(final ProducerCursor c) { + writer.enqueue(nextEvent(c)); + } + + @Benchmark + @Group("producers1") + @GroupThreads(1) + public void drain1(final Blackhole blackhole) { + drain(blackhole); + } + + // ---- 4 producers ---- + + @Benchmark + @Group("producers4") + @GroupThreads(4) + public void enqueue4(final ProducerCursor c) { + writer.enqueue(nextEvent(c)); + } + + @Benchmark + @Group("producers4") + @GroupThreads(1) + public void drain4(final Blackhole blackhole) { + drain(blackhole); + } + + // ---- 16 producers ---- + + @Benchmark + @Group("producers16") + @GroupThreads(16) + public void enqueue16(final ProducerCursor c) { + writer.enqueue(nextEvent(c)); + } + + @Benchmark + @Group("producers16") + @GroupThreads(1) + public void drain16(final Blackhole blackhole) { + drain(blackhole); + } + + private void drain(final Blackhole blackhole) { + for (int i = 0; i < DRAIN_BATCH; i++) { + final FlagEvalEvent event = writer.pollQueuedEventForTest(); + if (event == null) { + break; + } + blackhole.consume(event); + } + } + + private FlagEvalEvent nextEvent(final ProducerCursor c) { + final int i = c.cursor++; + return new FlagEvalEvent( + flagKeys[Math.floorMod(i, flagKeys.length)], + "variant-" + Math.floorMod(i, 4), + "alloc-" + Math.floorMod(i, flagKeys.length), + targetingKeys[Math.floorMod(i, targetingKeys.length)], + null, + 1_700_000_000_000L + i, + attrs); + } + + private static String[] keys(final String prefix, final int count) { + final String[] out = new String[count]; + for (int i = 0; i < count; i++) { + out[i] = prefix + i; + } + return out; + } +} diff --git a/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationHotPathBenchmark.java b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationHotPathBenchmark.java new file mode 100644 index 00000000000..6a1f6154b2f --- /dev/null +++ b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationHotPathBenchmark.java @@ -0,0 +1,160 @@ +package com.datadog.openfeature.internal.flagevaluation; + +import static java.util.concurrent.TimeUnit.NANOSECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; + +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import java.util.HashMap; +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; + +/** + * Writer-side benchmark for EVP {@code flagevaluation} recording: the bounded-queue enqueue and the + * worker-thread aggregation of already-captured events. + * + *

Scope limitation: this benchmark starts from a pre-built FlagEvalEvent + * holding a ready-made flat attribute map. It therefore does NOT cover the OpenFeature hook's own + * inline capture cost - in particular the bounded evaluation-context copy that + * FlagEvalLoggingHook.finallyAfter performs synchronously on the evaluation thread under + * consent-on. That cost is measured separately by FlagEvalHookHotPathBenchmark in the root package, + * where the hook and the OpenFeature context types live. Do not read writerEnqueue as the total + * per-evaluation cost paid by the caller. + * + *

Events are built with observeFullEvaluationData=true so the aggregator actually canonicalizes + * the context. Under the consent-off default it drops attrs and skips canonicalization, which makes + * every field-count profile measure the same scalar-only work. + * + *

Run: {@code ./gradlew :dd-openfeature:jmh -Pjmh.includes=FlagEvaluationHotPathBenchmark}. + */ +@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 FlagEvaluationHotPathBenchmark { + + @Param({ + "typical/100flags_50users_10fields", + "stress/10flags_1000users_250fields", + "scale/2500flags_500users_20fields" + }) + public String profile; + + private Map attrs; + private String[] flagKeys; + private String[] targetingKeys; + private int cursor; + private FlagEvaluationPipeline writer; + private FlagEvaluationPipeline.SerializingHandlerForTest handler; + + @Setup(Level.Iteration) + public void setUp() { + final Profile p = Profile.fromName(profile); + + attrs = new HashMap<>(); + for (int i = 0; i < p.numFields; i++) { + attrs.put("field" + i, "value"); + } + flagKeys = keys("bench-flag-", p.numFlags); + targetingKeys = keys("bench-user-", p.numUsers); + cursor = 0; + + final EventTransport transport = (route, json) -> {}; + final RuntimeServices services = new RuntimeServices(HealthMetrics.NOOP); + final Map ddContext = new HashMap<>(); + ddContext.put("service", "bench-service"); + handler = new FlagEvaluationPipeline.SerializingHandlerForTest(transport, ddContext, services); + + // Capacity large enough that the benchmark never overflows within a measurement window. + writer = + new FlagEvaluationPipeline( + 1 << 20, Long.MAX_VALUE, NANOSECONDS, transport, ddContext, services); + } + + /** + * Queue-mechanics cost only: event allocation plus the non-blocking bounded-queue offer. Excludes + * the hook's inline context snapshot - see the class javadoc. + */ + @Benchmark + public void writerEnqueue(final Blackhole blackhole) { + final FlagEvalEvent event = nextEvent(); + writer.enqueue(event); + blackhole.consume(writer.pollQueuedEventForTest()); + blackhole.consume(event); + } + + /** Worker-thread cost: materialize context, prune, canonicalize, and aggregate. */ + @Benchmark + public void workerAggregate(final Blackhole blackhole) { + final FlagEvalEvent event = nextEvent(); + handler.aggregateEvent(event); + if ((cursor % 10_000) == 0) { + handler.clearAggregationForTest(); + } + blackhole.consume(handler.fullTierSizeForTest()); + } + + private FlagEvalEvent nextEvent() { + final int i = cursor++; + // observeFullEvaluationData=true is required for this benchmark to mean anything: under the + // consent-off default the aggregator drops attrs and skips canonicalContextKey entirely, so + // every field-count profile would collapse to the same scalar-only cost. + return new FlagEvalEvent( + flagKeys[Math.floorMod(i, flagKeys.length)], + "variant-" + Math.floorMod(i, 4), + "alloc-" + Math.floorMod(i, flagKeys.length), + targetingKeys[Math.floorMod(i, targetingKeys.length)], + null, + 1_700_000_000_000L + i, + true, + attrs); + } + + private static String[] keys(final String prefix, final int count) { + final String[] out = new String[count]; + for (int i = 0; i < count; i++) { + out[i] = prefix + i; + } + return out; + } + + private static final class Profile { + private final int numFlags; + private final int numUsers; + private final int numFields; + + private Profile(final int numFlags, final int numUsers, final int numFields) { + this.numFlags = numFlags; + this.numUsers = numUsers; + this.numFields = numFields; + } + + private static Profile fromName(final String name) { + if ("typical/100flags_50users_10fields".equals(name)) { + return new Profile(100, 50, 10); + } + if ("stress/10flags_1000users_250fields".equals(name)) { + return new Profile(10, 1_000, 250); + } + if ("scale/2500flags_500users_20fields".equals(name)) { + return new Profile(2_500, 500, 20); + } + throw new IllegalArgumentException("unknown benchmark profile: " + name); + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Allocation.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Allocation.java new file mode 100644 index 00000000000..a4d40e9e3c3 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Allocation.java @@ -0,0 +1,9 @@ +package com.datadog.openfeature.internal.exposure; + +public class Allocation { + public final String key; + + public Allocation(final String key) { + this.key = key; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureCache.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureCache.java new file mode 100644 index 00000000000..0ce6e6c31d9 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureCache.java @@ -0,0 +1,64 @@ +package com.datadog.openfeature.internal.exposure; + +import java.util.Objects; + +public interface ExposureCache { + + boolean add(ExposureEvent event); + + Value get(Key key); + + int size(); + + final class Key { + public final String flag; + public final String subject; + + public Key(final ExposureEvent event) { + this.flag = event.flag == null ? null : event.flag.key; + this.subject = event.subject == null ? null : event.subject.id; + } + + @Override + public boolean equals(final Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + final Key key = (Key) o; + return Objects.equals(flag, key.flag) && Objects.equals(subject, key.subject); + } + + @Override + public int hashCode() { + return Objects.hash(flag, subject); + } + } + + final class Value { + public final String variant; + public final String allocation; + public final Integer serialId; + + public Value(final ExposureEvent event) { + this.variant = event.variant == null ? null : event.variant.key; + this.allocation = event.allocation == null ? null : event.allocation.key; + this.serialId = event.serial_id; + } + + @Override + public boolean equals(final Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + final Value value = (Value) o; + return Objects.equals(variant, value.variant) + && Objects.equals(allocation, value.allocation) + && Objects.equals(serialId, value.serialId); + } + + @Override + public int hashCode() { + return Objects.hash(variant, allocation, serialId); + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureEvent.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureEvent.java new file mode 100644 index 00000000000..a49811908ba --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureEvent.java @@ -0,0 +1,26 @@ +package com.datadog.openfeature.internal.exposure; + +public class ExposureEvent { + // milliseconds since epoch as given by System.currentTimeMillis() + public final long timestamp; + public final Allocation allocation; + public final Flag flag; + public final Variant variant; + public final Subject subject; + public final Integer serial_id; + + public ExposureEvent( + final long timestamp, + final Allocation allocation, + final Flag flag, + final Variant variant, + final Subject subject, + final Integer serialId) { + this.timestamp = timestamp; + this.allocation = allocation; + this.flag = flag; + this.variant = variant; + this.subject = subject; + this.serial_id = serialId; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java new file mode 100644 index 00000000000..c0385845ce2 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java @@ -0,0 +1,164 @@ +package com.datadog.openfeature.internal.exposure; + +import com.datadog.openfeature.internal.EventQueue; +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.connector.EventTransport; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Deduplicates and batches exposure events to the event platform {@code exposures} route. */ +public class ExposurePipeline implements Consumer, AutoCloseable { + + private static final Logger LOGGER = LoggerFactory.getLogger(ExposurePipeline.class); + public static final int DEFAULT_CAPACITY = 1 << 16; // 65536 elements + public static final int DEFAULT_FLUSH_INTERVAL_IN_SECONDS = 1; + private static final int FLUSH_THRESHOLD = 100; + private static final String EXPOSURES_ROUTE = "exposures"; + + private final EventQueue queue; + private final Thread serializerThread; + + public ExposurePipeline( + final int capacity, + final long flushInterval, + final TimeUnit timeUnit, + final EventTransport transport, + final Map context, + final RuntimeServices services) { + this.queue = new EventQueue<>(capacity); + final ExposureSerializingHandler serializer = + new ExposureSerializingHandler(transport, queue, flushInterval, timeUnit, context); + this.serializerThread = services.newThread("exposures", serializer); + } + + /** Starts the background serializing thread. */ + public void start() { + this.serializerThread.start(); + } + + @Override + public void close() { + if (this.serializerThread.isAlive()) { + this.serializerThread.interrupt(); + } + } + + @Override + public void accept(final ExposureEvent event) { + queue.offer(event); + } + + boolean isSerializerThreadAlive() { + return serializerThread.isAlive(); + } + + int queueSize() { + return queue.size(); + } + + private static class ExposureSerializingHandler implements Runnable { + private final EventQueue queue; + private final long ticksRequiredToFlush; + private long lastTicks; + + private final EventTransport transport; + private final Map context; + private final ExposureCache cache; + + private final List buffer = new ArrayList<>(); + + ExposureSerializingHandler( + final EventTransport transport, + final EventQueue queue, + final long flushInterval, + final TimeUnit timeUnit, + final Map context) { + this.queue = queue; + this.cache = new LRUExposureCache(queue.capacity()); + this.transport = transport; + this.context = context; + + this.lastTicks = System.nanoTime(); + this.ticksRequiredToFlush = timeUnit.toNanos(flushInterval); + + LOGGER.debug("starting exposure serializer"); + } + + @Override + public void run() { + try { + runDutyCycle(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + LOGGER.debug("exposure processor worker exited. submitting exposures stopped."); + } + + private void runDutyCycle() throws InterruptedException { + final Thread thread = Thread.currentThread(); + while (!thread.isInterrupted()) { + ExposureEvent event; + while ((event = queue.poll(100, TimeUnit.MILLISECONDS)) != null) { + if (addToBuffer(event)) { + consumeBatch(); + break; + } + } + flushIfNecessary(); + } + } + + private void consumeBatch() { + queue.drain(this::addToBuffer, queue.size()); + } + + /** Adds an element to the buffer taking care of duplicated exposures thanks to the LRU cache */ + private boolean addToBuffer(final ExposureEvent event) { + if (cache.add(event)) { + buffer.add(event); + return true; + } + return false; + } + + protected void flushIfNecessary() { + if (buffer.isEmpty()) { + return; + } + if (shouldFlush()) { + final byte[] payload; + try { + final ExposuresRequest exposures = new ExposuresRequest(this.context, this.buffer); + payload = exposures.serialize(); + } catch (RuntimeException e) { + LOGGER.error("Could not serialize exposures; dropping batch", e); + this.buffer.clear(); + return; + } + try { + transport.post(EXPOSURES_ROUTE, payload); + } catch (Exception e) { + LOGGER.debug("Could not submit exposures", e); + } finally { + // Best-effort delivery must not retry an ambiguously accepted batch. + this.buffer.clear(); + } + } + } + + private boolean shouldFlush() { + long nanoTime = System.nanoTime(); + long ticks = nanoTime - lastTicks; + if (ticks > ticksRequiredToFlush || queue.size() >= FLUSH_THRESHOLD) { + lastTicks = nanoTime; + return true; + } + return false; + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposuresRequest.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposuresRequest.java new file mode 100644 index 00000000000..c1522dc210e --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposuresRequest.java @@ -0,0 +1,53 @@ +package com.datadog.openfeature.internal.exposure; + +import static com.datadog.openfeature.internal.JsonWriting.writeKeyObject; +import static com.datadog.openfeature.internal.JsonWriting.writeValue; + +import com.datadog.openfeature.internal.JsonWriting; +import java.util.List; +import java.util.Map; + +public class ExposuresRequest { + public final Map context; + public final List exposures; + + public ExposuresRequest(final Map context, final List exposures) { + this.context = context; + this.exposures = exposures; + } + + /** + * @return the UTF-8 JSON payload. + */ + public byte[] serialize() { + return JsonWriting.write( + generator -> { + generator.writeStartObject(); + generator.writeFieldName("context"); + writeValue(generator, this.context); + generator.writeArrayFieldStart("exposures"); + for (final ExposureEvent exposure : this.exposures) { + generator.writeStartObject(); + generator.writeNumberField("timestamp", exposure.timestamp); + writeKeyObject(generator, "allocation", exposure.allocation.key); + writeKeyObject(generator, "flag", exposure.flag.key); + writeKeyObject(generator, "variant", exposure.variant.key); + generator.writeObjectFieldStart("subject"); + if (exposure.subject.id != null) { + generator.writeStringField("id", exposure.subject.id); + } + if (exposure.subject.attributes != null) { + generator.writeFieldName("attributes"); + writeValue(generator, exposure.subject.attributes); + } + generator.writeEndObject(); + if (exposure.serial_id != null) { + generator.writeNumberField("serial_id", exposure.serial_id); + } + generator.writeEndObject(); + } + generator.writeEndArray(); + generator.writeEndObject(); + }); + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Flag.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Flag.java new file mode 100644 index 00000000000..0085cfe4d8a --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Flag.java @@ -0,0 +1,9 @@ +package com.datadog.openfeature.internal.exposure; + +public class Flag { + public final String key; + + public Flag(final String key) { + this.key = key; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/LRUExposureCache.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/LRUExposureCache.java new file mode 100644 index 00000000000..8a83e6df745 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/LRUExposureCache.java @@ -0,0 +1,41 @@ +package com.datadog.openfeature.internal.exposure; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * This class is intentionally not thread-safe. Thread safety is ensured by the single-threaded + * access pattern managed by {@link ExposureWriterImpl.ExposureSerializingHandler}. + */ +public class LRUExposureCache implements ExposureCache { + + private final Map cache; + + public LRUExposureCache(final int capacity) { + cache = + new LinkedHashMap(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(final Map.Entry eldest) { + return super.size() > capacity; + } + }; + } + + @Override + public boolean add(final ExposureEvent event) { + final Key key = new Key(event); + final Value value = new Value(event); + final Value oldValue = cache.put(key, value); + return oldValue == null || !oldValue.equals(value); + } + + @Override + public Value get(final Key key) { + return cache.get(key); + } + + @Override + public int size() { + return cache.size(); + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Subject.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Subject.java new file mode 100644 index 00000000000..2ba7cfab153 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Subject.java @@ -0,0 +1,13 @@ +package com.datadog.openfeature.internal.exposure; + +import java.util.Map; + +public class Subject { + public final String id; + public final Map attributes; + + public Subject(final String id, final Map attributes) { + this.id = id; + this.attributes = attributes; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Variant.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Variant.java new file mode 100644 index 00000000000..ce5ddb2ae01 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Variant.java @@ -0,0 +1,9 @@ +package com.datadog.openfeature.internal.exposure; + +public class Variant { + public final String key; + + public Variant(final String key) { + this.key = key; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEvent.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEvent.java new file mode 100644 index 00000000000..f31ea9a3da1 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEvent.java @@ -0,0 +1,98 @@ +package com.datadog.openfeature.internal.flagevaluation; + +import java.util.Collections; +import java.util.Map; + +/** + * Lightweight data record capturing a single flag evaluation for EVP flagevaluation emission. + * + *

This is the currency passed from the FlagEvalLoggingHook (feature-flagging-api) to the + * FlagEvaluationWriter (feature-flagging-lib) via a non-blocking bounded queue. + * + *

Scalar fields and context attributes are captured at hook-fire time on the evaluation thread. + * No aggregation happens here. + */ +public final class FlagEvalEvent { + + /** The feature flag key. Never null. */ + public final String flagKey; + + /** + * The OpenFeature variant key selected for the evaluation. Null means the default value was + * returned (runtime default). + */ + public final String variant; + + /** The allocation key from flag metadata ("allocationKey"). May be null. */ + public final String allocationKey; + + /** The targeting key from the evaluation context. May be null. */ + public final String targetingKey; + + /** + * The evaluation error message when the evaluation failed, else null. Sourced from the + * OpenFeature evaluation details (error message, falling back to the error code). + */ + public final String errorMessage; + + /** + * Evaluation timestamp in milliseconds since epoch. Stamped at eval-entry time from flag metadata + * key __dd_eval_timestamp_ms, or falls back to hook-fire time when absent. This ensures + * first/last_evaluation reflect evaluation time, not hook-fire time. + */ + public final long evalTimeMs; + + /** + * Flattened evaluation context attributes. Used for the full-tier canonical context key. May be + * empty but never null. + */ + public final Map attrs; + + /** + * PII consent from the ServerConfiguration used by the evaluation. When false (privacy-preserving + * default), the targeting key is hashed and the per-evaluation context is omitted on emission. + */ + public final boolean observeFullEvaluationData; + + /** Convenience constructor; consent defaults to the privacy-preserving false. */ + public FlagEvalEvent( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final long evalTimeMs, + final Map attrs) { + this(flagKey, variant, allocationKey, targetingKey, null, evalTimeMs, false, attrs); + } + + /** Convenience constructor; consent defaults to the privacy-preserving false. */ + public FlagEvalEvent( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final String errorMessage, + final long evalTimeMs, + final Map attrs) { + this(flagKey, variant, allocationKey, targetingKey, errorMessage, evalTimeMs, false, attrs); + } + + public FlagEvalEvent( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final String errorMessage, + final long evalTimeMs, + final boolean observeFullEvaluationData, + final Map attrs) { + this.flagKey = flagKey; + this.variant = variant; + this.allocationKey = allocationKey; + this.targetingKey = targetingKey; + this.errorMessage = errorMessage; + this.evalTimeMs = evalTimeMs; + this.observeFullEvaluationData = observeFullEvaluationData; + this.attrs = attrs != null ? attrs : Collections.emptyMap(); + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregator.java new file mode 100644 index 00000000000..ed35250b7b9 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregator.java @@ -0,0 +1,463 @@ +package com.datadog.openfeature.internal.flagevaluation; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +final class FlagEvaluationAggregator { + private static int hash(Object first, Object second, Object third) { + return 31 * 31 * java.util.Objects.hashCode(first) + + 31 * java.util.Objects.hashCode(second) + + java.util.Objects.hashCode(third); + } + + private static int addToHash(int hash, Object value) { + return 31 * hash + java.util.Objects.hashCode(value); + } + + private static int addToHash(int hash, boolean value) { + return 31 * hash + Boolean.hashCode(value); + } + + // Design assumptions — document the scale we sized for + static final int EXPECTED_FLAG_COUNT = 2_500; + static final int EXPECTED_FULL_BUCKETS_PER_FLAG = 50; + static final int EXPECTED_USERS_PER_FLAG = 1_000; + static final int PER_FLAG_HEADROOM_MULTIPLIER = 10; + static final int EXPECTED_DEGRADED_BUCKETS_PER_FLAG = 10; + + // Derived sizing — show the math behind the bucket caps below + static final int FULL_BUCKET_SIZING_BASIS = + EXPECTED_FLAG_COUNT * EXPECTED_FULL_BUCKETS_PER_FLAG; // 125_000 + static final int PER_FLAG_BUCKET_SIZING_BASIS = + PER_FLAG_HEADROOM_MULTIPLIER * EXPECTED_USERS_PER_FLAG; // 10_000 + static final int DEGRADED_BUCKET_SIZING_BASIS = + EXPECTED_FLAG_COUNT * EXPECTED_DEGRADED_BUCKETS_PER_FLAG; // 25_000 + + // Enforced bucket caps + static final int GLOBAL_CAP = 131_072; // nearest power of two above FULL_BUCKET_SIZING_BASIS + static final int PER_FLAG_CAP = PER_FLAG_BUCKET_SIZING_BASIS; + static final int DEGRADED_CAP = 32_768; // nearest power of two above DEGRADED_BUCKET_SIZING_BASIS + + private static final byte CTX_TAG_STRING = 's'; + private static final byte CTX_TAG_BOOL = 'b'; + private static final byte CTX_TAG_INT = 'i'; + private static final byte CTX_TAG_LONG = 'l'; + private static final byte CTX_TAG_FLOAT = 'f'; + private static final byte CTX_TAG_DOUBLE = 'd'; + private static final byte CTX_TAG_OTHER = 'o'; + + final Map fullTier = new HashMap<>(); + final Map degradedTier = new HashMap<>(); + final Map perFlagCount = new HashMap<>(); + final AtomicLong droppedDegradedOverflow = new AtomicLong(0); + final AtomicInteger globalFullCount = new AtomicInteger(0); + + void aggregate(final FlagEvalEvent event) { + final boolean isDefault = event.variant == null; + final boolean observeFullEvaluationData = event.observeFullEvaluationData; + // On the protected path the context is dropped on emit, so it must not fragment buckets or be + // stored — otherwise a high-cardinality field (request_id, timestamp) blows out PER_FLAG_CAP + // and spills every subsequent evaluation into the degraded tier. + final Map prunedAttrs = observeFullEvaluationData ? event.attrs : null; + final String ctxKey = observeFullEvaluationData ? canonicalContextKey(prunedAttrs) : ""; + final FullKey fullKey = buildFullKey(event, ctxKey); + + EvalBucket bucket = fullTier.get(fullKey); + if (bucket != null) { + bucket.merge(event.evalTimeMs, isDefault); + bucket.observeFullEvaluationData &= observeFullEvaluationData; + return; + } + + final int flagCount = perFlagCount.getOrDefault(event.flagKey, 0); + if (globalFullCount.get() < GLOBAL_CAP && flagCount < PER_FLAG_CAP) { + fullTier.put( + fullKey, + new EvalBucket( + event.flagKey, + event.variant, + event.allocationKey, + event.targetingKey, + event.errorMessage, + event.evalTimeMs, + isDefault, + prunedAttrs, + observeFullEvaluationData)); + globalFullCount.incrementAndGet(); + perFlagCount.put(event.flagKey, flagCount + 1); + return; + } + + final DegradedKey degradedKey = buildDegradedKey(event); + bucket = degradedTier.get(degradedKey); + if (bucket != null) { + bucket.merge(event.evalTimeMs, isDefault); + bucket.observeFullEvaluationData &= observeFullEvaluationData; + return; + } + + if (degradedTier.size() < DEGRADED_CAP) { + degradedTier.put( + degradedKey, + new EvalBucket( + event.flagKey, + event.variant, + event.allocationKey, + null, + event.errorMessage, + event.evalTimeMs, + isDefault, + null, + observeFullEvaluationData)); + return; + } + + droppedDegradedOverflow.incrementAndGet(); + } + + boolean isEmpty() { + return fullTier.isEmpty() && degradedTier.isEmpty(); + } + + int fullTierSize() { + return fullTier.size(); + } + + long degradedEvaluationCount() { + long count = 0; + for (final EvalBucket bucket : degradedTier.values()) { + count += bucket.count; + } + return count; + } + + int bucketCount() { + return fullTier.size() + degradedTier.size(); + } + + Iterable fullBuckets() { + return fullTier.values(); + } + + Iterable degradedBuckets() { + return degradedTier.values(); + } + + void clear() { + fullTier.clear(); + degradedTier.clear(); + perFlagCount.clear(); + globalFullCount.set(0); + } + + AggregatedState snapshot() { + return new AggregatedState( + new HashMap<>(fullTier), new HashMap<>(degradedTier), droppedDegradedOverflow.get()); + } + + void simulateFullTierAtCap() { + for (int i = globalFullCount.get(); i < GLOBAL_CAP; i++) { + final String key = "synthetic-full-" + i; + fullTier.put( + new FullKey(key, "on", "alloc", false, null, null, "", false), + new EvalBucket(key, "on", "alloc", null, null, 1L, false, null, false)); + globalFullCount.incrementAndGet(); + perFlagCount.merge(key, 1, Integer::sum); + } + } + + void simulateDegradedTierAtCap() { + for (int i = degradedTier.size(); i < DEGRADED_CAP; i++) { + final String key = "synthetic-dg-" + i; + degradedTier.put( + new DegradedKey(key, "on", "alloc", false, null), + new EvalBucket(key, "on", "alloc", null, null, 1L, false, null, false)); + } + } + + void addDegradedBucketForTest( + final String flagKey, + final String variant, + final String allocationKey, + final String errorMessage, + final long evalTimeMs) { + degradedTier.put( + new DegradedKey(flagKey, variant, allocationKey, variant == null, errorMessage), + new EvalBucket( + flagKey, + variant, + allocationKey, + null, + errorMessage, + evalTimeMs, + variant == null, + null, + false)); + } + + private static FullKey buildFullKey(final FlagEvalEvent event, final String ctxKey) { + return new FullKey( + event.flagKey, + event.variant, + event.allocationKey, + event.variant == null, + event.errorMessage, + event.targetingKey, + ctxKey, + event.observeFullEvaluationData); + } + + private static DegradedKey buildDegradedKey(final FlagEvalEvent event) { + return new DegradedKey( + event.flagKey, + event.variant, + event.allocationKey, + event.variant == null, + event.errorMessage); + } + + static String canonicalContextKey(final Map prunedAttrs) { + if (prunedAttrs == null || prunedAttrs.isEmpty()) { + return ""; + } + final Map sorted = + (prunedAttrs instanceof TreeMap) ? prunedAttrs : new TreeMap<>(prunedAttrs); + final StringBuilder sb = new StringBuilder(); + for (final Map.Entry entry : sorted.entrySet()) { + appendLengthDelimited(sb, entry.getKey()); + appendContextValue(sb, entry.getValue()); + } + return sb.toString(); + } + + private static final String HEX_ZEROS = "00000000"; + + private static void appendLengthDelimited(final StringBuilder sb, final String s) { + // 8-char zero-padded hex length prefix, allocation-lean (no String.format on the hot path). + final String hexLength = Integer.toHexString(s.length()); + sb.append(HEX_ZEROS, 0, 8 - hexLength.length()); + sb.append(hexLength); + sb.append(s); + } + + private static void appendContextValue(final StringBuilder sb, final Object v) { + if (v instanceof Boolean) { + sb.append((char) CTX_TAG_BOOL); + appendLengthDelimited(sb, v.toString()); + } else if (v instanceof Long) { + sb.append((char) CTX_TAG_LONG); + appendLengthDelimited(sb, v.toString()); + } else if (v instanceof Integer) { + sb.append((char) CTX_TAG_INT); + appendLengthDelimited(sb, v.toString()); + } else if (v instanceof Float) { + sb.append((char) CTX_TAG_FLOAT); + appendLengthDelimited(sb, v.toString()); + } else if (v instanceof Double) { + sb.append((char) CTX_TAG_DOUBLE); + appendLengthDelimited(sb, v.toString()); + } else if (v instanceof String) { + sb.append((char) CTX_TAG_STRING); + appendLengthDelimited(sb, (String) v); + } else { + sb.append((char) CTX_TAG_OTHER); + appendLengthDelimited(sb, v == null ? "" : v.toString()); + } + } + + static class EvalBucket { + long count; + long firstEvalMs; + long lastEvalMs; + boolean runtimeDefaultUsed; + String flagKey; + String variant; + String allocationKey; + String targetingKey; + String errorMessage; + Map prunedAttrs; + // Consent to emit raw PII. For full-tier buckets this is uniform (consent is a FullKey + // dimension) and the AND-fold on merge is defensive. For degraded-tier buckets consent is NOT + // a key dimension — mixed-consent events merge here — so the AND-fold produces false whenever + // any consent-off event lands in the bucket. That's benign because the degraded wire path + // drops the targeting key and context regardless of consent, so this field has no downstream + // effect for degraded rows. + boolean observeFullEvaluationData; + + EvalBucket( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final String errorMessage, + final long evalTimeMs, + final boolean runtimeDefaultUsed, + final Map prunedAttrs, + final boolean observeFullEvaluationData) { + this.flagKey = flagKey; + this.variant = variant; + this.allocationKey = allocationKey; + this.targetingKey = targetingKey; + this.errorMessage = errorMessage; + this.firstEvalMs = evalTimeMs; + this.lastEvalMs = evalTimeMs; + this.count = 1; + this.runtimeDefaultUsed = runtimeDefaultUsed; + this.prunedAttrs = prunedAttrs; + this.observeFullEvaluationData = observeFullEvaluationData; + } + + int prunedContextFieldCount() { + return prunedAttrs == null ? 0 : prunedAttrs.size(); + } + + void merge(final long evalTimeMs, final boolean isDefault) { + count++; + if (evalTimeMs < firstEvalMs) { + firstEvalMs = evalTimeMs; + } + if (evalTimeMs > lastEvalMs) { + lastEvalMs = evalTimeMs; + } + if (isDefault) { + runtimeDefaultUsed = true; + } + } + } + + static final class FullKey { + private final String flagKey; + private final String variant; + private final String allocationKey; + private final boolean runtimeDefaultUsed; + private final String errorMessage; + private final String targetingKey; + private final String contextKey; + // Part of the key so consent-on and consent-off evaluations never share a bucket. The + // serializer branches on this to hash the targeting key and drop the context, so events with + // different consent produce different wire rows and belong in different buckets. + private final boolean observeFullEvaluationData; + + FullKey( + final String flagKey, + final String variant, + final String allocationKey, + final boolean runtimeDefaultUsed, + final String errorMessage, + final String targetingKey, + final String contextKey, + final boolean observeFullEvaluationData) { + this.flagKey = flagKey; + this.variant = variant; + this.allocationKey = allocationKey; + this.runtimeDefaultUsed = runtimeDefaultUsed; + this.errorMessage = errorMessage; + this.targetingKey = targetingKey; + this.contextKey = contextKey; + this.observeFullEvaluationData = observeFullEvaluationData; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof FullKey)) { + return false; + } + final FullKey fullKey = (FullKey) o; + return runtimeDefaultUsed == fullKey.runtimeDefaultUsed + && observeFullEvaluationData == fullKey.observeFullEvaluationData + && Objects.equals(flagKey, fullKey.flagKey) + && Objects.equals(variant, fullKey.variant) + && Objects.equals(allocationKey, fullKey.allocationKey) + && Objects.equals(errorMessage, fullKey.errorMessage) + && Objects.equals(targetingKey, fullKey.targetingKey) + && Objects.equals(contextKey, fullKey.contextKey); + } + + @Override + public int hashCode() { + // HashingUtils avoids the Object[] allocation and boolean boxing that Objects.hash performs + // on this hot bucket-lookup path. + int result = hash(flagKey, variant, allocationKey); + result = addToHash(result, runtimeDefaultUsed); + result = addToHash(result, errorMessage); + result = addToHash(result, targetingKey); + result = addToHash(result, contextKey); + return addToHash(result, observeFullEvaluationData); + } + } + + static final class DegradedKey { + // Unlike FullKey, consent is NOT a bucket dimension here: the wire serializer for degraded rows + // (FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket with isFullTier=false) drops the + // targeting key and context unconditionally, so two degraded buckets differing only in consent + // would emit byte-identical JSON with evaluation_count split — halving effective DEGRADED_CAP + // for zero wire fidelity. Mixed-consent events merge into one bucket; the AND-fold on + // EvalBucket.observeFullEvaluationData still runs but has no downstream effect for degraded + // rows. + private final String flagKey; + private final String variant; + private final String allocationKey; + private final boolean runtimeDefaultUsed; + private final String errorMessage; + + DegradedKey( + final String flagKey, + final String variant, + final String allocationKey, + final boolean runtimeDefaultUsed, + final String errorMessage) { + this.flagKey = flagKey; + this.variant = variant; + this.allocationKey = allocationKey; + this.runtimeDefaultUsed = runtimeDefaultUsed; + this.errorMessage = errorMessage; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof DegradedKey)) { + return false; + } + final DegradedKey that = (DegradedKey) o; + return runtimeDefaultUsed == that.runtimeDefaultUsed + && Objects.equals(flagKey, that.flagKey) + && Objects.equals(variant, that.variant) + && Objects.equals(allocationKey, that.allocationKey) + && Objects.equals(errorMessage, that.errorMessage); + } + + @Override + public int hashCode() { + // HashingUtils avoids the Object[] allocation and boolean boxing that Objects.hash performs + // on this hot bucket-lookup path. + int result = hash(flagKey, variant, allocationKey); + result = addToHash(result, runtimeDefaultUsed); + return addToHash(result, errorMessage); + } + } + + static class AggregatedState { + final Map fullTier; + final Map degradedTier; + final long droppedDegradedOverflow; + + AggregatedState( + final Map fullTier, + final Map degradedTier, + final long droppedDegradedOverflow) { + this.fullTier = fullTier; + this.degradedTier = degradedTier; + this.droppedDegradedOverflow = droppedDegradedOverflow; + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloads.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloads.java new file mode 100644 index 00000000000..90ced6c3ac4 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloads.java @@ -0,0 +1,338 @@ +package com.datadog.openfeature.internal.flagevaluation; + +import static com.datadog.openfeature.internal.JsonWriting.writeKeyObject; +import static com.datadog.openfeature.internal.JsonWriting.writeValue; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.datadog.openfeature.internal.JsonWriting; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +final class FlagEvaluationPayloads { + + private static final byte[] PAYLOAD_SUFFIX = "]}".getBytes(UTF_8); + private static final byte[] JSON_COMMA = ",".getBytes(UTF_8); + + /** + * Wire prefix identifying a privacy-preserving, hashed targeting key. Emitted for full-tier rows + * when {@code observeFullEvaluationData} is off. The suffix is the lower-case hex SHA-256 of the + * UTF-8 targeting key (see {@link #hashTargetingKey}). This is a cross-SDK wire contract - keep + * it in sync with the other server SDKs and the UFC/EVP spec. + */ + private static final String HASHED_TARGETING_KEY_PREFIX = "sha256_"; + + // Per-thread SHA-256 instance as hashing runs for every full-tier row without consent. + private static final ThreadLocal SHA_256 = + ThreadLocal.withInitial( + () -> { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (final NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + }); + + private FlagEvaluationPayloads() {} + + static EncodedPayloads buildPayloads( + final List events, + final Map context, + final int payloadSizeLimitBytes) { + final byte[] prefix = payloadPrefix(context); + EncodedPayloadBuilder current = new EncodedPayloadBuilder(prefix); + final List payloads = new ArrayList<>(); + long droppedPayloadLimit = 0; + long degradedPayloadLimit = 0; + + for (final FlagEvaluationEvent event : events) { + byte[] eventBytes = encodeEvent(event); + + if (!current.canAdd(eventBytes, payloadSizeLimitBytes) && !current.isEmpty()) { + payloads.add(current.toByteArray()); + current = new EncodedPayloadBuilder(prefix); + } + + if (current.canAdd(eventBytes, payloadSizeLimitBytes)) { + current.add(eventBytes); + continue; + } + + final FlagEvaluationEvent degraded = event.withoutTargetingKeyAndContext(); + if (degraded != null) { + eventBytes = encodeEvent(degraded); + if (!current.canAdd(eventBytes, payloadSizeLimitBytes) && !current.isEmpty()) { + payloads.add(current.toByteArray()); + current = new EncodedPayloadBuilder(prefix); + } + if (current.canAdd(eventBytes, payloadSizeLimitBytes)) { + current.add(eventBytes); + degradedPayloadLimit += event.evaluation_count; + continue; + } + } + + droppedPayloadLimit += event.evaluation_count; + } + + if (!current.isEmpty()) { + payloads.add(current.toByteArray()); + } + return new EncodedPayloads(payloads, droppedPayloadLimit, degradedPayloadLimit); + } + + private static byte[] payloadPrefix(final Map context) { + final byte[] contextJson = JsonWriting.write(generator -> writeValue(generator, context)); + return ("{\"context\":" + new String(contextJson, UTF_8) + ",\"flagEvaluations\":[") + .getBytes(UTF_8); + } + + static byte[] encodeEvent(final FlagEvaluationEvent event) { + return JsonWriting.write( + generator -> { + generator.writeStartObject(); + generator.writeNumberField("timestamp", event.timestamp); + writeKeyObject(generator, "flag", event.flag.key); + generator.writeNumberField("first_evaluation", event.first_evaluation); + generator.writeNumberField("last_evaluation", event.last_evaluation); + generator.writeNumberField("evaluation_count", event.evaluation_count); + writeKeyObject(generator, "variant", keyOf(event.variant)); + writeKeyObject(generator, "allocation", keyOf(event.allocation)); + if (event.targeting_key != null) { + generator.writeStringField("targeting_key", event.targeting_key); + } + if (event.runtime_default_used != null) { + generator.writeBooleanField("runtime_default_used", event.runtime_default_used); + } + if (event.context != null) { + generator.writeObjectFieldStart("context"); + generator.writeFieldName("evaluation"); + writeValue(generator, event.context.evaluation); + generator.writeEndObject(); + } + if (event.error != null) { + generator.writeObjectFieldStart("error"); + generator.writeStringField("message", event.error.message); + generator.writeEndObject(); + } + generator.writeEndObject(); + }); + } + + /** + * Hashes a targeting key. + * + * @param value the targeting key. + * @return the lower-case hex SHA-256 of the UTF-8 targeting key. + */ + static String hashTargetingKey(final String value) { + final MessageDigest digest = SHA_256.get(); + digest.reset(); + final byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + final StringBuilder hex = new StringBuilder(hash.length * 2); + for (final byte b : hash) { + final int v = b & 0xFF; + if (v < 0x10) { + hex.append('0'); + } + hex.append(Integer.toHexString(v)); + } + return hex.toString(); + } + + static final class EncodedPayloads { + final List bodies; + final long droppedPayloadLimit; + final long degradedPayloadLimit; + + private EncodedPayloads( + final List bodies, + final long droppedPayloadLimit, + final long degradedPayloadLimit) { + this.bodies = bodies; + this.droppedPayloadLimit = droppedPayloadLimit; + this.degradedPayloadLimit = degradedPayloadLimit; + } + } + + private static final class EncodedPayloadBuilder { + private final byte[] prefix; + private final List events = new ArrayList<>(); + private int eventBytes; + + private EncodedPayloadBuilder(final byte[] prefix) { + this.prefix = prefix; + } + + private boolean isEmpty() { + return events.isEmpty(); + } + + private boolean canAdd(final byte[] event, final int payloadSizeLimitBytes) { + return sizeWith(event) <= payloadSizeLimitBytes; + } + + private int sizeWith(final byte[] event) { + return prefix.length + PAYLOAD_SUFFIX.length + eventBytes + event.length + events.size(); + } + + private void add(final byte[] event) { + events.add(event); + eventBytes += event.length; + } + + private byte[] toByteArray() { + final int size = prefix.length + PAYLOAD_SUFFIX.length + eventBytes; + final ByteArrayOutputStream out = new ByteArrayOutputStream(size + events.size()); + out.write(prefix, 0, prefix.length); + for (int i = 0; i < events.size(); i++) { + if (i > 0) { + out.write(JSON_COMMA, 0, JSON_COMMA.length); + } + final byte[] event = events.get(i); + out.write(event, 0, event.length); + } + out.write(PAYLOAD_SUFFIX, 0, PAYLOAD_SUFFIX.length); + return out.toByteArray(); + } + } + + static class FlagEvaluationEvent { + public final long timestamp; + public final FlagKeyObject flag; + public final long first_evaluation; + public final long last_evaluation; + public final long evaluation_count; + public final KeyObject variant; + public final KeyObject allocation; + public final String targeting_key; + public final Boolean runtime_default_used; + public final EventContext context; + public final ErrorObject error; + + FlagEvaluationEvent( + final long timestamp, + final String flagKey, + final long firstEvalMs, + final long lastEvalMs, + final long count, + final String variant, + final String allocation, + final String targetingKey, + final boolean runtimeDefaultUsed, + final String errorMessage, + final Map evaluationAttrs) { + this.timestamp = timestamp; + this.flag = new FlagKeyObject(flagKey); + this.first_evaluation = firstEvalMs; + this.last_evaluation = lastEvalMs; + this.evaluation_count = count; + this.variant = (variant != null && !variant.isEmpty()) ? new KeyObject(variant) : null; + this.allocation = + (allocation != null && !allocation.isEmpty()) ? new KeyObject(allocation) : null; + this.targeting_key = targetingKey; + this.runtime_default_used = runtimeDefaultUsed ? Boolean.TRUE : null; + this.context = + (evaluationAttrs != null && !evaluationAttrs.isEmpty()) + ? new EventContext(evaluationAttrs) + : null; + this.error = + (errorMessage != null && !errorMessage.isEmpty()) ? new ErrorObject(errorMessage) : null; + } + + static FlagEvaluationEvent fromBucket( + final FlagEvaluationAggregator.EvalBucket bucket, + final boolean isFullTier, + final boolean observeFullEvaluationData, + final long flushTimeMs) { + final boolean includeRawContext = isFullTier && observeFullEvaluationData; + return new FlagEvaluationEvent( + flushTimeMs, + bucket.flagKey, + bucket.firstEvalMs, + bucket.lastEvalMs, + bucket.count, + bucket.variant, + bucket.allocationKey, + resolveTargetingKey(bucket.targetingKey, isFullTier, observeFullEvaluationData), + bucket.runtimeDefaultUsed, + bucket.errorMessage, + includeRawContext ? bucket.prunedAttrs : null); + } + + private static String resolveTargetingKey( + final String rawTargetingKey, + final boolean isFullTier, + final boolean observeFullEvaluationData) { + if (!isFullTier || rawTargetingKey == null) { + return null; + } + if (observeFullEvaluationData) { + return rawTargetingKey; + } + return HASHED_TARGETING_KEY_PREFIX + hashTargetingKey(rawTargetingKey); + } + + FlagEvaluationEvent withoutTargetingKeyAndContext() { + if (targeting_key == null && context == null) { + return null; + } + return new FlagEvaluationEvent( + timestamp, + flag.key, + first_evaluation, + last_evaluation, + evaluation_count, + keyOf(variant), + keyOf(allocation), + null, + Boolean.TRUE.equals(runtime_default_used), + messageOf(error), + null); + } + } + + private static String keyOf(final KeyObject object) { + return object == null ? null : object.key; + } + + private static String messageOf(final ErrorObject object) { + return object == null ? null : object.message; + } + + static class KeyObject { + public final String key; + + KeyObject(final String key) { + this.key = key; + } + } + + static class FlagKeyObject { + public final String key; + + FlagKeyObject(final String key) { + this.key = key; + } + } + + static class ErrorObject { + public final String message; + + ErrorObject(final String message) { + this.message = message; + } + } + + static class EventContext { + public final Map evaluation; + + EventContext(final Map evaluation) { + this.evaluation = evaluation; + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java new file mode 100644 index 00000000000..8888f6331ba --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java @@ -0,0 +1,543 @@ +package com.datadog.openfeature.internal.flagevaluation; + +import com.datadog.openfeature.internal.EventQueue; +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.connector.EventTransport; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * EVP flagevaluation writer for Java. + * + *

Uses the same event transport as the exposure pipeline, with two-tier aggregation replacing + * the single-exposure buffer. + * + *

Two-tier aggregation contract: Full key: (flagKey, variant, allocationKey, runtimeDefault, + * errorMessage, targetingKey, canonical-context-key). Degraded key: (flagKey, variant, + * allocationKey, runtimeDefault, errorMessage) - no targetingKey/context. Canonical context key: + * sorted entries, type-tagged length-delimited encoding - NOT a hash (collision-safe, comparable + * string identity). Context pruning: deterministic (sort before cut), <=256 fields, string values + * <=256 chars; the pruned attributes are what gets aggregated and serialized. Caps: + * globalCap=131072, perFlagCap=10000, degradedCap=32768. Eval-time: min/max of + * firstEvalMs/lastEvalMs across events in the same bucket. Runtime default: absent variant means + * runtimeDefaultUsed=true. Flush interval: 10 seconds. Queue: bounded lock-free queue (capacity + * 2^12), non-blocking offer; on overflow the event is dropped and the droppedQueueOverflow counter + * is incremented and surfaced on flush. Enqueue: lock-free. Producers contend only on the MPSC + * queue, never on a monitor, so evaluation threads do not serialize against each other. Shutdown: + * close() drains the queue and performs a final flush before the worker thread exits. Because + * enqueue is lock-free, a producer can still offer during shutdown; close() sweeps the queue once + * the worker has been joined, counting any remainder as a closed drop so shutdown loss is + * observable rather than silent. + */ +public class FlagEvaluationPipeline implements FlagEvaluationWriter, AutoCloseable { + + private static final Logger LOGGER = LoggerFactory.getLogger(FlagEvaluationPipeline.class); + + public static final int DEFAULT_CAPACITY = 1 << 12; // 4096 elements, per cross-SDK RFC + public static final int FLUSH_INTERVAL_SECONDS = 10; + + static final int FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES = 5 * 1024 * 1024; + static final String FLAG_EVALUATION_DROPPED_METRIC = "flagevaluation.rows.dropped"; + static final String FLAG_EVALUATION_DEGRADED_METRIC = "flagevaluation.rows.degraded"; + static final String FLAG_EVALUATION_SPLITS_METRIC = "flagevaluation.payload.splits"; + static final String FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC = "flagevaluation.context.truncated"; + static final String DROP_REASON_QUEUE_OVERFLOW = "queue_overflow"; + static final String DROP_REASON_CLOSED = "closed"; + static final String DROP_REASON_DEGRADED_CAP = "degraded_cap"; + static final String DROP_REASON_PAYLOAD_LIMIT = "payload_limit"; + static final String DEGRADED_REASON_CARDINALITY_CAP = "cardinality_cap"; + static final String DEGRADED_REASON_PAYLOAD_LIMIT = "payload_limit"; + private static final String FLAG_EVALUATION_ROUTE = "flagevaluation"; + private final RuntimeServices services; + + private final EventQueue queue; + private final FlagEvaluationSerializingHandler serializer; + private final Thread serializerThread; + private final Object lifecycleLock = new Object(); + private final AtomicBoolean closed = new AtomicBoolean(false); + + private void countMetric(final String metricName, final long value, final String reason) { + services.countMetric(metricName, value, reason); + } + + /** + * Observable counter for events dropped because the bounded hand-off queue was full when the hook + * tried to enqueue (backpressure). Incremented on the hook thread, surfaced on flush. + */ + private final AtomicLong droppedQueueOverflow = new AtomicLong(0); + + /** + * Per-reason-tag counters for evaluations whose context was truncated by copyPrunedContext. Keyed + * by the sorted comma-separated reason string (e.g. "max_key_length,max_value_length"). + * Incremented on the hook thread, drained and emitted on flush. + */ + private final ConcurrentHashMap contextTruncatedCounts = + new ConcurrentHashMap<>(); + + public FlagEvaluationPipeline( + final int capacity, + final long flushInterval, + final TimeUnit timeUnit, + final EventTransport transport, + final Map context, + final RuntimeServices services) { + this.services = services; + this.queue = new EventQueue<>(capacity); + this.serializer = + new FlagEvaluationSerializingHandler( + transport, + queue, + flushInterval, + timeUnit, + context, + droppedQueueOverflow, + contextTruncatedCounts, + FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES, + services); + this.serializerThread = services.newThread("evaluations", serializer); + } + + /** Starts the background serializing thread. */ + public void start() { + synchronized (lifecycleLock) { + if (closed.get()) { + return; + } + this.serializerThread.start(); + } + } + + /** Test seam: current full-tier bucket count in the worker's aggregator. */ + int aggregatorFullTierSizeForTest() { + return serializer.aggregator.fullTierSize(); + } + + @Override + public void close() { + final boolean workerRunning; + synchronized (lifecycleLock) { + if (!closed.compareAndSet(false, true)) { + return; + } + workerRunning = this.serializerThread.isAlive(); + if (workerRunning) { + // Ask the worker to drain the queue and final-flush, then interrupt to wake it from poll(). + serializer.requestShutdown(); + this.serializerThread.interrupt(); + } + } + if (Thread.currentThread() == this.serializerThread) { + return; + } + if (workerRunning) { + try { + // Bounded wait for the worker's final flush so queued events are not lost on shutdown. + this.serializerThread.join(TimeUnit.SECONDS.toMillis(5)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + // enqueue() is lock-free, so a producer that passed the closed check before close() ran can + // still land an event after the worker's final drain. Sweep the remainder so that loss is + // counted rather than silently stranded in a queue nobody polls again. Only safe once the + // worker is gone: the queue is single-consumer, so sweeping alongside a live worker would + // break that contract. If join() timed out the worker is still draining, so skip the sweep. + if (!this.serializerThread.isAlive()) { + sweepAndCountResidualEvents(); + } + } + + /** + * Counts events left in the queue after the worker has exited. Must only be called when the + * serializer thread is not alive - the queue permits a single consumer. + */ + private void sweepAndCountResidualEvents() { + long residual = 0; + while (queue.poll() != null) { + residual++; + } + countMetric(FLAG_EVALUATION_DROPPED_METRIC, residual, DROP_REASON_CLOSED); + } + + @Override + public void enqueue(final FlagEvalEvent event) { + if (event == null) { + return; + } + if (closed.get()) { + countClosedDrop(); + return; + } + // Deliberately lock-free: the hand-off queue is MPSC by design, so serializing producers on a + // monitor here would negate that and turn every evaluation in every application thread into + // contention on one lock. A producer that passed the check above can still offer after close() + // has started; that residue is accounted for by the worker's bounded post-drain passes and by + // close()'s post-join sweep, so shutdown loss stays observable. + // + // Safe publication of the event (including the context snapshot built by the hook) comes from + // the queue's own offer/poll ordering, not from any monitor held here. + // + // Non-blocking offer. Count overflow so loss is observable rather than silent; the count is + // surfaced on the next flush. The hook's pre-queue guard (see FlagEvalLoggingHook) samples the + // queue depth before doing any context-copy work, so a saturated queue costs an + // AtomicInteger.get(); the offer here still races with the worker and can legitimately fail. + if (!queue.offer(event)) { + droppedQueueOverflow.incrementAndGet(); + } + } + + @Override + public boolean hasCapacityForEnqueue() { + return queue.size() < queue.capacity(); + } + + @Override + public void countPreQueueOverflow() { + droppedQueueOverflow.incrementAndGet(); + } + + @Override + public void countContextTruncated(final String reason) { + contextTruncatedCounts.computeIfAbsent(reason, k -> new AtomicLong(0)).incrementAndGet(); + } + + private void countClosedDrop() { + countMetric(FLAG_EVALUATION_DROPPED_METRIC, 1, DROP_REASON_CLOSED); + } + + /** Returns the count of events dropped due to queue-overflow backpressure (observable). */ + long droppedQueueOverflow() { + return droppedQueueOverflow.get(); + } + + /** Test seam: returns one queued event without starting the worker. */ + FlagEvalEvent pollQueuedEventForTest() { + return queue.poll(); + } + + /** Test seam: flushes serializer state without starting the worker. */ + void flushForTest() { + serializer.flush(); + } + + // ---- Serializing handler (background thread logic) ---- + + static class FlagEvaluationSerializingHandler implements Runnable { + private final RuntimeServices services; + + private void countMetric(String name, long value, String reason) { + services.countMetric(name, value, reason); + } + + private final EventQueue queue; + private final long ticksRequiredToFlush; + + @SuppressFBWarnings( + value = "AT_NONATOMIC_64BIT_PRIMITIVE", + justification = "the field is confined to the single serializer thread") + private long lastTicks; + + private final EventTransport transport; + final Map context; + private final AtomicLong droppedQueueOverflow; + private final ConcurrentHashMap contextTruncatedCounts; + private final int payloadSizeLimitBytes; + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + + // Shutdown coordination: set by close(), drives a final drain+flush before the worker exits. + private final AtomicBoolean shutdownRequested = new AtomicBoolean(false); + private final CountDownLatch finalFlushDone = new CountDownLatch(1); + + FlagEvaluationSerializingHandler( + final EventTransport transport, + final EventQueue queue, + final long flushInterval, + final TimeUnit timeUnit, + final Map context, + final AtomicLong droppedQueueOverflow, + final ConcurrentHashMap contextTruncatedCounts, + final int payloadSizeLimitBytes, + final RuntimeServices services) { + this.services = services; + this.queue = queue; + this.transport = transport; + this.context = context; + this.droppedQueueOverflow = droppedQueueOverflow; + this.contextTruncatedCounts = contextTruncatedCounts; + this.payloadSizeLimitBytes = payloadSizeLimitBytes; + this.lastTicks = System.nanoTime(); + this.ticksRequiredToFlush = timeUnit.toNanos(flushInterval); + LOGGER.debug("starting flag evaluation serializer"); + } + + /** Signals the worker to drain the queue and perform a final flush before exiting. */ + void requestShutdown() { + shutdownRequested.set(true); + } + + @Override + public void run() { + try { + runDutyCycle(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + // On exit (interrupt or shutdown request), drain everything still buffered and flush it so + // queued events are not lost on shutdown. + // + // close() interrupts this thread to break it out of poll(). The final flush does socket + // I/O, which fails fast on a thread whose interrupt flag is set, so clear the flag + // for the duration of the drain and restore it afterwards. Without this the flush that + // close() exists to guarantee throws IOException, the aggregated rows are discarded by + // the clear() in flush(), and the loss is invisible to the drop counters because those + // rows already left the queue. + final boolean wasInterrupted = Thread.interrupted(); + try { + drainAndFlush(); + } finally { + finalFlushDone.countDown(); + if (wasInterrupted) { + Thread.currentThread().interrupt(); + } + } + } + LOGGER.debug("flag evaluation processor worker exited."); + } + + private void runDutyCycle() throws InterruptedException { + final Thread thread = Thread.currentThread(); + while (!thread.isInterrupted() && !shutdownRequested.get()) { + final FlagEvalEvent event = queue.poll(100, TimeUnit.MILLISECONDS); + if (event != null) { + aggregateEvent(event); + } + flushIfNecessary(); + } + } + + void drainAndFlush() { + FlagEvalEvent event; + while ((event = queue.poll()) != null) { + aggregateEvent(event); + } + flush(); + } + + // ---- Aggregation logic ---- + + /** Routes an event into the full tier or degraded tier, or drops and counts on overflow. */ + void aggregateEvent(final FlagEvalEvent event) { + try { + aggregator.aggregate(event); + } catch (LinkageError | RuntimeException e) { + LOGGER.debug("Could not aggregate flag evaluation event", e); + } + } + + // ---- Flush logic ---- + + void flushIfNecessary() { + if (shouldFlush()) { + flush(); + } + } + + void flush() { + // Surface backpressure (queue-overflow) drops as an observable warning even when there is + // nothing else to flush. + final long qDrops = droppedQueueOverflow.getAndSet(0); + countMetric(FLAG_EVALUATION_DROPPED_METRIC, qDrops, DROP_REASON_QUEUE_OVERFLOW); + if (qDrops > 0) { + LOGGER.warn( + "flag evaluation queue full - dropped {} evaluation(s) under backpressure" + + " (best-effort telemetry)", + qDrops); + } + final long dgDrops = aggregator.droppedDegradedOverflow.getAndSet(0); + countMetric(FLAG_EVALUATION_DROPPED_METRIC, dgDrops, DROP_REASON_DEGRADED_CAP); + if (dgDrops > 0) { + LOGGER.warn( + "degraded aggregation tier full - dropped {} evaluation(s); raise degraded cap" + + " (best-effort telemetry)", + dgDrops); + } + + // Drain per-reason context-truncation counters and emit one metric per unique reason tag. + for (final Map.Entry entry : contextTruncatedCounts.entrySet()) { + final long count = entry.getValue().getAndSet(0); + if (count > 0) { + countMetric(FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC, count, entry.getKey()); + } + } + + if (aggregator.isEmpty()) { + return; + } + try { + countMetric( + FLAG_EVALUATION_DEGRADED_METRIC, + aggregator.degradedEvaluationCount(), + DEGRADED_REASON_CARDINALITY_CAP); + final List events = buildEventList(); + if (events.isEmpty()) { + return; + } + final FlagEvaluationPayloads.EncodedPayloads payloads = + FlagEvaluationPayloads.buildPayloads(events, context, payloadSizeLimitBytes); + countMetric( + FLAG_EVALUATION_DROPPED_METRIC, + payloads.droppedPayloadLimit, + DROP_REASON_PAYLOAD_LIMIT); + countMetric( + FLAG_EVALUATION_DEGRADED_METRIC, + payloads.degradedPayloadLimit, + DEGRADED_REASON_PAYLOAD_LIMIT); + if (payloads.bodies.size() > 1) { + countMetric(FLAG_EVALUATION_SPLITS_METRIC, payloads.bodies.size() - 1, null); + } + if (payloads.droppedPayloadLimit > 0) { + LOGGER.warn( + "flag evaluation payload too large - dropped {} evaluation(s)" + + " (best-effort telemetry)", + payloads.droppedPayloadLimit); + } + for (final byte[] payload : payloads.bodies) { + transport.post(FLAG_EVALUATION_ROUTE, payload); + } + } catch (Exception e) { + LOGGER.error("Could not submit flag evaluations", e); + } finally { + // Best-effort: always clear the aggregator after a flush attempt. Retaining buckets across + // flushes on encode failure would let one unserializable value (for example a NaN Double a + // customer put in the context) permanently block every subsequent flush. + aggregator.clear(); + lastTicks = System.nanoTime(); + } + } + + private List buildEventList() { + final long flushTimeMs = System.currentTimeMillis(); + // Consent is read per bucket from the value each event snapshotted at evaluation time, not + // from the gateway here: CURRENT_CONFIG may have been overwritten by a later RC update since + // these evaluations happened, and reading it at flush would apply the wrong config's consent. + final List events = + new ArrayList<>(aggregator.bucketCount()); + for (final FlagEvaluationAggregator.EvalBucket bucket : aggregator.fullBuckets()) { + events.add( + FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( + bucket, true, bucket.observeFullEvaluationData, flushTimeMs)); + } + for (final FlagEvaluationAggregator.EvalBucket bucket : aggregator.degradedBuckets()) { + events.add( + FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( + bucket, false, bucket.observeFullEvaluationData, flushTimeMs)); + } + return events; + } + + private boolean shouldFlush() { + if (aggregator.isEmpty() && droppedQueueOverflow.get() == 0) { + return false; + } + final long nanoTime = System.nanoTime(); + final long ticks = nanoTime - lastTicks; + if (ticks > ticksRequiredToFlush) { + lastTicks = nanoTime; + return true; + } + return false; + } + } + + // ---- Test-seam inner class (package-private) ---- + + /** + * Test-accessible handler that exposes {@link #drainAndAggregate()} and {@link #flush()} without + * starting a real background thread. + */ + static class SerializingHandlerForTest extends FlagEvaluationSerializingHandler { + + SerializingHandlerForTest( + final EventTransport transport, + final Map context, + final RuntimeServices services) { + this(transport, context, FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES, services); + } + + SerializingHandlerForTest( + final EventTransport transport, + final Map context, + final int payloadSizeLimitBytes, + final RuntimeServices services) { + super( + transport, + new EventQueue<>(DEFAULT_CAPACITY), + Long.MAX_VALUE, // effectively never auto-flush + TimeUnit.NANOSECONDS, + context, + new AtomicLong(0), + new ConcurrentHashMap<>(), + payloadSizeLimitBytes, + services); + } + + private final List staged = new ArrayList<>(); + + /** Adds an event to the staged list (simulates hook enqueue). */ + void add(final FlagEvalEvent event) { + staged.add(event); + } + + /** Aggregates all staged events and returns the current aggregation state. */ + FlagEvaluationAggregator.AggregatedState drainAndAggregate() { + for (final FlagEvalEvent e : staged) { + aggregateEvent(e); + } + staged.clear(); + return aggregator.snapshot(); + } + + /** Simulates filling the full tier to GLOBAL_CAP by injecting synthetic distinct buckets. */ + void simulateFullTierAtCap() { + aggregator.simulateFullTierAtCap(); + } + + /** + * Simulates filling the degraded tier to DEGRADED_CAP by injecting synthetic distinct buckets. + */ + void simulateDegradedTierAtCap() { + aggregator.simulateDegradedTierAtCap(); + } + + void addDroppedDegradedOverflowForTest(final long count) { + aggregator.droppedDegradedOverflow.addAndGet(count); + } + + void addDegradedBucketForTest( + final String flagKey, + final String variant, + final String allocationKey, + final String errorMessage, + final long evalTimeMs) { + aggregator.addDegradedBucketForTest( + flagKey, variant, allocationKey, errorMessage, evalTimeMs); + } + + void clearAggregationForTest() { + aggregator.clear(); + } + + int fullTierSizeForTest() { + return aggregator.fullTierSize(); + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationWriter.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationWriter.java new file mode 100644 index 00000000000..5733ef94d34 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationWriter.java @@ -0,0 +1,33 @@ +package com.datadog.openfeature.internal.flagevaluation; + +/** Records flag evaluation events from the evaluation thread, without blocking. */ +public interface FlagEvaluationWriter { + /** + * Reports whether the async hand-off queue currently has room for another event. Producers + * consult this before performing any expensive context-copy work so a saturated queue is observed + * as an O(1) read rather than a full snapshot followed by a discarded offer. Best-effort only: + * the worker can drain (or a peer producer can fill) between this check and the subsequent + * enqueue, so callers must still tolerate offer failure. + * + * @return {@code true} if the queue currently has room for another event. + */ + boolean hasCapacityForEnqueue(); + + /** Counts one queue-overflow drop without offering an event. */ + void countPreQueueOverflow(); + + /** + * Counts one evaluation whose context was truncated. + * + * @param reason the sorted, comma-separated set of the caps that fired. + */ + void countContextTruncated(String reason); + + /** + * Non-blocking enqueue of a flag evaluation event. Drops the event, and counts the drop, if the + * queue is full. + * + * @param event the event to enqueue. + */ + void enqueue(FlagEvalEvent event); +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineTest.java new file mode 100644 index 00000000000..3595fdc6dc4 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineTest.java @@ -0,0 +1,299 @@ +package com.datadog.openfeature.internal.exposure; + +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonMap; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.datadog.openfeature.internal.JsonReading; +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BooleanSupplier; +import org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; + +class ExposurePipelineTest { + private static final long TIMEOUT_NANOS = SECONDS.toNanos(5); + + private final List> requests = new CopyOnWriteArrayList<>(); + private final AtomicInteger posts = new AtomicInteger(); + private volatile int failingPost = -1; + + private final EventTransport transport = + (route, json) -> { + assertEquals("exposures", route); + if (this.posts.incrementAndGet() == this.failingPost) { + throw new IOException("ambiguous timeout"); + } + this.requests.add(JsonReading.readObject(json)); + }; + + @TableTest({ + "service | env | version", + " | | ", + "'test-service' | 'test' | '23' ", + "'test-service' | | '23' ", + "'test-service' | 'test' | " + }) + void testExposureEventWrites(final String service, final String env, final String version) + throws Exception { + final Map context = new HashMap<>(); + putIfNotNull(context, "service", service); + putIfNotNull(context, "env", env); + putIfNotNull(context, "version", version); + final List exposures = buildExposures(5); + + try (ExposurePipeline pipeline = pipeline(context)) { + pipeline.start(); + exposures.forEach(pipeline); + + eventually(() -> allExposures().size() == exposures.size()); + } + + for (final Map request : this.requests) { + assertEquals(context, request.get("context")); + } + assertEquals(keys(exposures), exposureKeys(allExposures())); + } + + @Test + void serializesExposureWireShape() throws Exception { + final ExposureEvent exposure = + new ExposureEvent( + 1234L, + new Allocation("allocation"), + new Flag("flag"), + new Variant("variant"), + new Subject("subject", singletonMap("tier", "gold")), + 7); + + try (ExposurePipeline pipeline = pipeline(emptyMap())) { + pipeline.start(); + pipeline.accept(exposure); + + eventually(() -> !allExposures().isEmpty()); + } + + final Map json = allExposures().get(0); + assertEquals(1234L, json.get("timestamp")); + assertEquals(singletonMap("key", "allocation"), json.get("allocation")); + assertEquals(singletonMap("key", "flag"), json.get("flag")); + assertEquals(singletonMap("key", "variant"), json.get("variant")); + final Map subject = new HashMap<>(); + subject.put("id", "subject"); + subject.put("attributes", singletonMap("tier", "gold")); + assertEquals(subject, json.get("subject")); + assertEquals(7L, json.get("serial_id")); + } + + @Test + void testLruCache() throws Exception { + final List exposures = buildExposures(6); + + try (ExposurePipeline pipeline = pipeline(emptyMap())) { + pipeline.start(); + // populating the cache + exposures.forEach(pipeline); + + // all events are written + eventually(() -> allExposures().size() == exposures.size()); + + // publishing duplicate events + exposures.forEach(pipeline); + + // no events are written + MILLISECONDS.sleep(300); // wait until a flush happens + assertEquals(exposures.size(), allExposures().size()); + + // a new event is generated + pipeline.accept(buildExposure()); + + // oldest event is evicted and the new one is submitted + eventually(() -> allExposures().size() == exposures.size() + 1); + } + } + + @Test + void testHighLoadScenario() throws Exception { + final int exposuresPerThread = 100; + final int threads = Runtime.getRuntime().availableProcessors(); + final ExecutorService executor = Executors.newFixedThreadPool(threads); + final List exposures = buildExposures(threads * exposuresPerThread); + final CountDownLatch latch = new CountDownLatch(1); + + try (ExposurePipeline pipeline = + new ExposurePipeline( + ExposurePipeline.DEFAULT_CAPACITY, + 100, + MILLISECONDS, + this.transport, + emptyMap(), + new RuntimeServices(HealthMetrics.NOOP))) { + pipeline.start(); + final List> futures = new ArrayList<>(); + for (int index = 0; index < exposures.size(); index += exposuresPerThread) { + final List partition = + exposures.subList(index, Math.min(index + exposuresPerThread, exposures.size())); + futures.add( + executor.submit( + () -> { + latch.await(); + partition.forEach(pipeline); + return true; + })); + } + latch.countDown(); // start threads + for (final Future future : futures) { + assertTrue(future.get()); // wait for all threads to finish + } + + eventually(() -> allExposures().size() == exposures.size()); + assertEquals(keys(exposures), exposureKeys(allExposures())); + } finally { + executor.shutdownNow(); + } + } + + @Test + void testAmbiguousExposureBatchIsNotRetried() throws Exception { + this.failingPost = 1; + final List exposures = buildExposures(2); + + try (ExposurePipeline pipeline = pipeline(emptyMap())) { + pipeline.start(); + pipeline.accept(exposures.get(0)); + eventually(() -> this.posts.get() == 1); + MILLISECONDS.sleep(300); + assertEquals(1, this.posts.get()); + + pipeline.accept(exposures.get(1)); + eventually(() -> !allExposures().isEmpty()); + } + + assertEquals(keys(exposures.subList(1, 2)), exposureKeys(allExposures())); + } + + @Test + void testNonFiniteAttributesDoNotPoisonFollowingExposures() throws Exception { + final ExposureEvent invalid = buildExposure(singletonMap("invalid", Double.NaN)); + final ExposureEvent valid = buildExposure(); + + try (ExposurePipeline pipeline = pipeline(emptyMap())) { + pipeline.start(); + pipeline.accept(invalid); + pipeline.accept(valid); + + eventually(() -> allExposures().size() == 2); + } + + assertEquals(keys(List.of(invalid, valid)), exposureKeys(allExposures())); + } + + @Test + void closeStopsTheSerializer() throws Exception { + final ExposurePipeline pipeline = pipeline(emptyMap()); + pipeline.start(); + assertTrue(pipeline.isSerializerThreadAlive()); + + pipeline.close(); + + eventually(() -> !pipeline.isSerializerThreadAlive()); + assertFalse(pipeline.isSerializerThreadAlive()); + } + + private ExposurePipeline pipeline(final Map context) { + return new ExposurePipeline( + 1 << 4, + 100, + MILLISECONDS, + this.transport, + context, + new RuntimeServices(HealthMetrics.NOOP)); + } + + @SuppressWarnings("unchecked") + private List> allExposures() { + final List> exposures = new ArrayList<>(); + for (final Map request : this.requests) { + exposures.addAll((List>) request.get("exposures")); + } + return exposures; + } + + private static Set keys(final List exposures) { + final Set keys = new HashSet<>(); + for (final ExposureEvent exposure : exposures) { + keys.add(exposure.flag.key + "/" + exposure.subject.id); + } + return keys; + } + + @SuppressWarnings("unchecked") + private static Set exposureKeys(final List> exposures) { + final Set keys = new HashSet<>(); + for (final Map exposure : exposures) { + keys.add( + ((Map) exposure.get("flag")).get("key") + + "/" + + ((Map) exposure.get("subject")).get("id")); + } + return keys; + } + + private static List buildExposures(final int size) { + final List exposures = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + exposures.add(buildExposure()); + } + return exposures; + } + + private static ExposureEvent buildExposure() { + return buildExposure(emptyMap()); + } + + private static ExposureEvent buildExposure(final Map attributes) { + return new ExposureEvent( + System.currentTimeMillis(), + new Allocation(UUID.randomUUID().toString()), + new Flag(UUID.randomUUID().toString()), + new Variant(UUID.randomUUID().toString()), + new Subject(UUID.randomUUID().toString(), attributes), + null); + } + + private static void putIfNotNull( + final Map map, final String key, final String value) { + if (value != null) { + map.put(key, value); + } + } + + private static void eventually(final BooleanSupplier condition) throws InterruptedException { + final long deadline = System.nanoTime() + TIMEOUT_NANOS; + while (!condition.getAsBoolean()) { + if (System.nanoTime() > deadline) { + throw new AssertionError("Condition not met in time"); + } + MILLISECONDS.sleep(10); + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposuresRequestTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposuresRequestTest.java new file mode 100644 index 00000000000..c4a61ba23a7 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposuresRequestTest.java @@ -0,0 +1,62 @@ +package com.datadog.openfeature.internal.exposure; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static java.util.Collections.singletonMap; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.datadog.openfeature.internal.JsonReading; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ExposuresRequestTest { + @Test + void serializesSerialIdUnderTheIntakeWireKey() { + assertTrue(exposureJson(340132).contains("\"serial_id\":340132")); + } + + @Test + void serializesSerialIdZeroRatherThanOmittingIt() { + assertTrue(exposureJson(0).contains("\"serial_id\":0")); + } + + @Test + void omitsSerialIdKeyWhenAbsent() { + assertFalse(exposureJson(null).contains("serial_id")); + } + + @Test + void serializesContextAndSubject() { + final ExposureEvent event = + new ExposureEvent( + 1234L, + new Allocation("allocation"), + new Flag("flag"), + new Variant("variant"), + new Subject(null, singletonMap("score", 42)), + null); + + final Map json = + JsonReading.readObject( + new ExposuresRequest(singletonMap("service", "svc"), singletonList(event)).serialize()); + + assertEquals(singletonMap("service", "svc"), json.get("context")); + final Map exposure = (Map) ((java.util.List) json.get("exposures")).get(0); + assertEquals(singletonMap("attributes", singletonMap("score", 42L)), exposure.get("subject")); + } + + private static String exposureJson(final Integer serialId) { + final ExposureEvent event = + new ExposureEvent( + 1234L, + new Allocation("allocation"), + new Flag("flag"), + new Variant("variant"), + new Subject("subject", emptyMap()), + serialId); + return new String(new ExposuresRequest(emptyMap(), singletonList(event)).serialize(), UTF_8); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/LRUExposureCacheTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/LRUExposureCacheTest.java new file mode 100644 index 00000000000..f48c3e32e0a --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/LRUExposureCacheTest.java @@ -0,0 +1,308 @@ +package com.datadog.openfeature.internal.exposure; + +import static java.util.Collections.emptyMap; +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 java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class LRUExposureCacheTest { + + @Test + void testAddingElements() { + LRUExposureCache cache = new LRUExposureCache(5); + ExposureEvent event = createEvent("flag", "subject", "variant", "allocation"); + + boolean added = cache.add(event); + + assertTrue(added); + assertEquals(1, cache.size()); + } + + @Test + void testAddingDuplicateEventsReturnsFalse() { + LRUExposureCache cache = new LRUExposureCache(5); + ExposureEvent event = createEvent("flag", "subject", "variant", "allocation"); + + cache.add(event); + boolean duplicateAdded = cache.add(event); + + assertFalse(duplicateAdded); + assertEquals(1, cache.size()); + } + + @Test + void testAddingEventsWithSameKeyButDifferentDetailsUpdatesCache() { + LRUExposureCache cache = new LRUExposureCache(5); + ExposureEvent event1 = createEvent("flag", "subject", "variant1", "allocation1"); + ExposureEvent event2 = createEvent("flag", "subject", "variant2", "allocation2"); + ExposureCache.Key key = new ExposureCache.Key(event1); + + boolean added1 = cache.add(event1); + boolean added2 = cache.add(event2); + ExposureCache.Value retrieved = cache.get(key); + + assertTrue(added1); + assertTrue(added2); + assertEquals(1, cache.size()); + assertEquals("variant2", retrieved.variant); + assertEquals("allocation2", retrieved.allocation); + } + + @Test + void testSerialIdAppearingEmitsANewExposure() { + assertEquals(2, emissions(null, 7)); + } + + @Test + void testSerialIdDisappearingEmitsANewExposure() { + assertEquals(2, emissions(7, null)); + } + + @Test + void testSerialIdChangingEmitsANewExposure() { + assertEquals(2, emissions(7, 8)); + } + + @Test + void testSerialIdCycleEmitsAnExposurePerTransition() { + assertEquals(3, emissions(7, 8, 7)); + } + + @Test + void testUnchangedSerialIdIsDeduplicated() { + assertEquals(1, emissions(7, 7)); + } + + @Test + void testUnchangedAbsentSerialIdIsDeduplicated() { + assertEquals(1, emissions(null, null)); + } + + @Test + void testSerialIdZeroIsDistinguishedFromAbsent() { + assertEquals(2, emissions(null, 0)); + assertEquals(2, emissions(0, null)); + } + + @Test + void testSerialIdIsRetainedOnTheCachedValue() { + LRUExposureCache cache = new LRUExposureCache(5); + ExposureEvent event = createEvent("flag", "subject", "variant", "allocation", 0); + + cache.add(event); + + assertEquals(Integer.valueOf(0), cache.get(new ExposureCache.Key(event)).serialId); + } + + @Test + void testLruEvictionWhenCapacityExceeded() { + LRUExposureCache cache = new LRUExposureCache(2); + ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); + ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); + ExposureEvent event3 = createEvent("flag3", "subject3", "variant3", "allocation3"); + ExposureCache.Key key1 = new ExposureCache.Key(event1); + ExposureCache.Key key3 = new ExposureCache.Key(event3); + + cache.add(event1); + cache.add(event2); + cache.add(event3); + + assertEquals(2, cache.size()); + assertNull(cache.get(key1)); // event1 should be evicted + assertNotNull(cache.get(key3)); // event3 should be present + assertEquals("variant3", cache.get(key3).variant); + assertEquals("allocation3", cache.get(key3).allocation); + } + + @Test + void testSingleCapacityCache() { + LRUExposureCache cache = new LRUExposureCache(1); + ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); + ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); + + cache.add(event1); + cache.add(event2); + + assertEquals(1, cache.size()); + } + + @Test + void testZeroCapacityCache() { + LRUExposureCache cache = new LRUExposureCache(0); + ExposureEvent event = createEvent("flag", "subject", "variant", "allocation"); + + boolean added = cache.add(event); + + assertTrue(added); + assertEquals(0, cache.size()); + } + + @Test + void testEmptyCacheSize() { + LRUExposureCache cache = new LRUExposureCache(5); + + assertEquals(0, cache.size()); + } + + @Test + void testMultipleAdditionsWithSameFlagDifferentSubjects() { + LRUExposureCache cache = new LRUExposureCache(10); + List events = new ArrayList<>(); + for (int index = 0; index < 5; index++) { + events.add(createEvent("flag", "subject" + index, "variant", "allocation")); + } + + for (ExposureEvent event : events) { + assertTrue(cache.add(event)); + } + + assertEquals(5, cache.size()); + } + + @Test + void testMultipleAdditionsWithSameSubjectDifferentFlags() { + LRUExposureCache cache = new LRUExposureCache(10); + List events = new ArrayList<>(); + for (int index = 0; index < 5; index++) { + events.add(createEvent("flag" + index, "subject", "variant", "allocation")); + } + + for (ExposureEvent event : events) { + assertTrue(cache.add(event)); + } + + assertEquals(5, cache.size()); + } + + @Test + void testKeyEqualityWithNullValues() { + LRUExposureCache cache = new LRUExposureCache(5); + ExposureEvent event1 = + new ExposureEvent( + System.currentTimeMillis(), + new Allocation("allocation"), + new Flag(null), + new Variant("variant"), + new Subject(null, emptyMap()), + null); + ExposureEvent event2 = + new ExposureEvent( + System.currentTimeMillis(), + new Allocation("allocation"), + new Flag(null), + new Variant("variant"), + new Subject(null, emptyMap()), + null); + + cache.add(event1); + boolean duplicateAdded = cache.add(event2); + + assertFalse(duplicateAdded); + assertEquals(1, cache.size()); + } + + @Test + void testUpdatingExistingKeyMaintainsLruPosition() { + LRUExposureCache cache = new LRUExposureCache(3); + ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); + ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); + ExposureEvent event3 = createEvent("flag3", "subject3", "variant3", "allocation3"); + ExposureEvent event1Updated = createEvent("flag1", "subject1", "variant2", "allocation2"); + ExposureEvent event4 = createEvent("flag4", "subject4", "variant4", "allocation4"); + ExposureCache.Key key1 = new ExposureCache.Key(event1); + ExposureCache.Key key2 = new ExposureCache.Key(event2); + ExposureCache.Key key4 = new ExposureCache.Key(event4); + + cache.add(event1); + cache.add(event2); + cache.add(event3); + cache.add(event1Updated); // Updates event1, moves to most recent + cache.add(event4); // Should evict event2, not event1 + + assertEquals(3, cache.size()); + assertNotNull(cache.get(key1)); // event1 should be updated and present + assertEquals("variant2", cache.get(key1).variant); // verify it was updated + assertEquals("allocation2", cache.get(key1).allocation); + assertNull(cache.get(key2)); // event2 should be evicted + assertNotNull(cache.get(key4)); // event4 should be present + assertEquals("variant4", cache.get(key4).variant); + } + + @Test + void testDuplicateExposureKeepsSubjectHotInLruOrder() { + LRUExposureCache cache = new LRUExposureCache(3); + ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); + ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); + ExposureEvent event3 = createEvent("flag3", "subject3", "variant3", "allocation3"); + // same key + same details as event1: will go through the "duplicate" path + ExposureEvent event1Duplicate = createEvent("flag1", "subject1", "variant1", "allocation1"); + ExposureEvent event4 = createEvent("flag4", "subject4", "variant4", "allocation4"); + + ExposureCache.Key key1 = new ExposureCache.Key(event1); + ExposureCache.Key key2 = new ExposureCache.Key(event2); + ExposureCache.Key key4 = new ExposureCache.Key(event4); + + // Fill cache + boolean added1 = cache.add(event1); + boolean added2 = cache.add(event2); + boolean added3 = cache.add(event3); + + // Duplicate exposure for subject1: should *not* change size, but *should* bump recency + boolean duplicateAdded = cache.add(event1Duplicate); + + // Now push over capacity: the least recently used *non-hot* entry (event2) should be evicted + boolean added4 = cache.add(event4); + + assertTrue(added1); + assertTrue(added2); + assertTrue(added3); + assertFalse(duplicateAdded); // dedup correctly + assertTrue(added4); + + assertEquals(3, cache.size()); + + assertNotNull(cache.get(key1)); // hot subject1 should still be present + assertNull(cache.get(key2)); // subject2 should be evicted + assertNotNull(cache.get(key4)); // newest subject4 should be present + + assertEquals("variant1", cache.get(key1).variant); + assertEquals("allocation1", cache.get(key1).allocation); + } + + private static ExposureEvent createEvent( + String flag, String subject, String variant, String allocation) { + return createEvent(flag, subject, variant, allocation, null); + } + + private static ExposureEvent createEvent( + String flag, String subject, String variant, String allocation, Integer serialId) { + return new ExposureEvent( + System.currentTimeMillis(), + new Allocation(allocation), + new Flag(flag), + new Variant(variant), + new Subject(subject, emptyMap()), + serialId); + } + + /** + * Number of exposures a run of same-flag, same-subject evaluations emits: the cache reports true + * only for the ones it does not suppress as duplicates. + */ + private static int emissions(final Integer... serialIds) { + LRUExposureCache cache = new LRUExposureCache(5); + int emitted = 0; + for (Integer serialId : serialIds) { + if (cache.add(createEvent("flag", "subject", "variant", "allocation", serialId))) { + emitted++; + } + } + return emitted; + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregatorTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregatorTest.java new file mode 100644 index 00000000000..811cd0eaa08 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregatorTest.java @@ -0,0 +1,424 @@ +package com.datadog.openfeature.internal.flagevaluation; + +import static java.util.Collections.emptyMap; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class FlagEvaluationAggregatorTest { + + @Test + void identicalEventsAggregateIntoOneBucketWithCount2() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + + aggregator.aggregate(event("flag-a", "on", "alloc1", "user-1", 1000L, emptyMap())); + aggregator.aggregate(event("flag-a", "on", "alloc1", "user-1", 2000L, emptyMap())); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertEquals(1, state.fullTier.size()); + final FlagEvaluationAggregator.EvalBucket bucket = state.fullTier.values().iterator().next(); + assertEquals(2, bucket.count); + assertEquals(1000L, bucket.firstEvalMs); + assertEquals(2000L, bucket.lastEvalMs); + assertTrue(bucket.firstEvalMs <= bucket.lastEvalMs); + } + + @Test + void differentValueTypesProduceDifferentBuckets() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + final Map attrsInt = new HashMap<>(); + attrsInt.put("score", 1); + final Map attrsStr = new HashMap<>(); + attrsStr.put("score", "1"); + + aggregator.aggregate(event("flag-b", "on", "alloc1", "user-1", 1000L, true, attrsInt)); + aggregator.aggregate(event("flag-b", "on", "alloc1", "user-1", 1000L, true, attrsStr)); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertEquals(2, state.fullTier.size()); + } + + @Test + void nulCharactersInKeyFieldsDoNotCollide() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + + aggregator.aggregate(event("a\0b", "c", "alloc1", "user-1", 1000L, emptyMap())); + aggregator.aggregate(event("a", "b\0c", "alloc1", "user-1", 1000L, emptyMap())); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertEquals(2, state.fullTier.size()); + } + + @Test + void globalCapOverflowRoutesToDegradedTier() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + + aggregator.simulateFullTierAtCap(); + aggregator.aggregate(simpleEvent("extra-flag", "on")); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertTrue(state.degradedTier.size() > 0); + assertEquals(0, state.droppedDegradedOverflow); + } + + @Test + void degradedCapOverflowIncrementsDroppedCounter() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + + aggregator.simulateFullTierAtCap(); + aggregator.simulateDegradedTierAtCap(); + aggregator.aggregate(simpleEvent("drop-flag", "on")); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertTrue(state.droppedDegradedOverflow > 0); + } + + @Test + void perFlagCapOverflowRoutesToDegradedTierAndMergesSameDegradedKey() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + aggregator.perFlagCount.put("hot-flag", FlagEvaluationAggregator.PER_FLAG_CAP); + + aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-1", 1000L, emptyMap())); + aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-2", 2000L, emptyMap())); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertEquals(0, state.fullTier.size()); + assertEquals(1, state.degradedTier.size()); + final FlagEvaluationAggregator.EvalBucket bucket = + state.degradedTier.values().iterator().next(); + assertEquals(2, bucket.count); + assertEquals(1000L, bucket.firstEvalMs); + assertEquals(2000L, bucket.lastEvalMs); + } + + @Test + void mixedConsentDegradedEvaluationsMergeIntoOneBucket() { + // Mirror of mixedConsentEvaluationsForSameSubjectLandInDistinctBuckets, but for the degraded + // tier: the wire serializer for degraded rows drops the targeting key and context regardless + // of consent, so two events differing only in consent emit byte-identical JSON. They must + // share a bucket, otherwise DEGRADED_CAP is effectively halved for zero wire fidelity gain. + // The AND-fold on EvalBucket.observeFullEvaluationData still runs but the value has no + // downstream effect for degraded rows. + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + aggregator.perFlagCount.put("hot-flag", FlagEvaluationAggregator.PER_FLAG_CAP); + + aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-1", 1000L, true, emptyMap())); + aggregator.aggregate(event("hot-flag", "on", "alloc1", "user-2", 2000L, false, emptyMap())); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertEquals(0, state.fullTier.size()); + assertEquals(1, state.degradedTier.size()); + final FlagEvaluationAggregator.EvalBucket bucket = + state.degradedTier.values().iterator().next(); + assertEquals(2, bucket.count); + // AND-fold collapses to consent-off; benign for degraded rows but a documented invariant. + assertFalse(bucket.observeFullEvaluationData); + } + + @Test + void absentVariantSetsRuntimeDefaultUsed() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + + aggregator.aggregate(event("flag-c", null, "alloc1", "user-1", 1000L, emptyMap())); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertEquals(1, state.fullTier.size()); + final FlagEvaluationAggregator.EvalBucket bucket = state.fullTier.values().iterator().next(); + assertTrue(bucket.runtimeDefaultUsed); + } + + @Test + void aggregatorStoresPrunedAttrsVerbatim() { + // Hot-path hook (DDEvaluator#copyPrunedContext) now delivers an already-pruned map. + // The aggregator no longer re-prunes; it stores what it is given. + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + final Map preprunedAttrs = new HashMap<>(); + for (int i = 0; i < 100; i++) { + preprunedAttrs.put("key" + i, "v" + i); + } + + aggregator.aggregate(event("flag-d", "on", "alloc1", "user-1", 1000L, true, preprunedAttrs)); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + final FlagEvaluationAggregator.EvalBucket bucket = state.fullTier.values().iterator().next(); + assertEquals(100, bucket.prunedContextFieldCount()); + assertEquals(100, bucket.prunedAttrs.size()); + } + + @Test + void emptyContextInputsProduceEmptyCanonicalKey() { + assertEquals("", FlagEvaluationAggregator.canonicalContextKey(null)); + assertEquals("", FlagEvaluationAggregator.canonicalContextKey(emptyMap())); + } + + @Test + void canonicalContextKeyEncodesSupportedValueTypes() { + final Map attrs = new HashMap<>(); + attrs.put("bool", true); + attrs.put("double", 1.5d); + attrs.put("float", 1.25f); + attrs.put("int", 1); + attrs.put("long", 2L); + attrs.put("null", null); + attrs.put("object", new StringBuilder("other")); + attrs.put("string", "value"); + + final String key = FlagEvaluationAggregator.canonicalContextKey(attrs); + + assertEquals(key, FlagEvaluationAggregator.canonicalContextKey(new HashMap<>(attrs))); + assertTrue(key.contains("bool")); + assertTrue(key.contains("string")); + assertTrue(key.contains("other")); + } + + @Test + void aggregatorStoresPrePrunedAttrsWithoutRePruning() { + // Value-length pruning moved to DDEvaluator#copyPrunedContext (hot path). The aggregator + // stores what it is given, so any long value present in the input remains present. + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + final Map preprunedAttrs = new HashMap<>(); + preprunedAttrs.put("short-val", "ok"); + + aggregator.aggregate(event("flag-e", "on", "alloc1", "user-1", 1000L, true, preprunedAttrs)); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + final FlagEvaluationAggregator.EvalBucket bucket = state.fullTier.values().iterator().next(); + assertTrue(bucket.prunedAttrs.containsKey("short-val")); + } + + @Test + void capSizingUsesNamedScaleConstants() { + assertEquals(125_000, FlagEvaluationAggregator.FULL_BUCKET_SIZING_BASIS); + assertEquals(10_000, FlagEvaluationAggregator.PER_FLAG_BUCKET_SIZING_BASIS); + assertEquals(25_000, FlagEvaluationAggregator.DEGRADED_BUCKET_SIZING_BASIS); + assertEquals(131_072, FlagEvaluationAggregator.GLOBAL_CAP); + assertEquals(10_000, FlagEvaluationAggregator.PER_FLAG_CAP); + assertEquals(32_768, FlagEvaluationAggregator.DEGRADED_CAP); + } + + @Test + void flagEvalEventDoesNotCarryReason() { + final boolean hasReasonField = + Arrays.stream( + com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent.class + .getDeclaredFields()) + .anyMatch(field -> field.getName().equals("reason")); + + assertFalse(hasReasonField); + } + + @Test + void evalBucketTracksBoundsDefaultStateAndNullContextFieldCount() { + final FlagEvaluationAggregator.EvalBucket bucket = + new FlagEvaluationAggregator.EvalBucket( + "bucket-flag", "on", "alloc1", "user-1", null, 1000L, false, null, false); + + assertEquals(0, bucket.prunedContextFieldCount()); + + bucket.merge(900L, true); + bucket.merge(1100L, false); + bucket.merge(1000L, false); + + assertEquals(4, bucket.count); + assertEquals(900L, bucket.firstEvalMs); + assertEquals(1100L, bucket.lastEvalMs); + assertTrue(bucket.runtimeDefaultUsed); + } + + @Test + void fullKeyEqualityUsesEveryDimension() { + final FlagEvaluationAggregator.FullKey base = + fullKey("flag", "on", "alloc", false, "error", "user", "ctx"); + final FlagEvaluationAggregator.FullKey same = + fullKey("flag", "on", "alloc", false, "error", "user", "ctx"); + + assertEquals(base, base); + assertEquals(base, same); + assertEquals(base.hashCode(), same.hashCode()); + assertNotEquals(base, null); + assertNotEquals(base, "not-a-key"); + assertNotEquals(base, fullKey("other", "on", "alloc", false, "error", "user", "ctx")); + assertNotEquals(base, fullKey("flag", "off", "alloc", false, "error", "user", "ctx")); + assertNotEquals(base, fullKey("flag", "on", "other", false, "error", "user", "ctx")); + assertNotEquals(base, fullKey("flag", "on", "alloc", true, "error", "user", "ctx")); + assertNotEquals(base, fullKey("flag", "on", "alloc", false, "other", "user", "ctx")); + assertNotEquals(base, fullKey("flag", "on", "alloc", false, "error", "other", "ctx")); + assertNotEquals(base, fullKey("flag", "on", "alloc", false, "error", "user", "other")); + } + + @Test + void degradedKeyEqualityUsesEveryDimension() { + final FlagEvaluationAggregator.DegradedKey base = + degradedKey("flag", "on", "alloc", false, "error"); + final FlagEvaluationAggregator.DegradedKey same = + degradedKey("flag", "on", "alloc", false, "error"); + + assertEquals(base, base); + assertEquals(base, same); + assertEquals(base.hashCode(), same.hashCode()); + assertNotEquals(base, null); + assertNotEquals(base, "not-a-key"); + assertNotEquals(base, degradedKey("other", "on", "alloc", false, "error")); + assertNotEquals(base, degradedKey("flag", "off", "alloc", false, "error")); + assertNotEquals(base, degradedKey("flag", "on", "other", false, "error")); + assertNotEquals(base, degradedKey("flag", "on", "alloc", true, "error")); + assertNotEquals(base, degradedKey("flag", "on", "alloc", false, "other")); + } + + @Test + void mixedConsentEvaluationsForSameSubjectLandInDistinctBuckets() { + // Consent is part of FullKey: two evaluations that differ only in consent produce different + // wire rows (raw vs hashed targeting key, context vs no context) and belong in different + // buckets. Merging them would silently downgrade the consent-on row to the protected shape. + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 1000L, true, emptyMap())); + aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 2000L, false, emptyMap())); + + assertEquals(2, aggregator.fullTierSize()); + int onCount = 0; + int offCount = 0; + for (final FlagEvaluationAggregator.EvalBucket bucket : + aggregator.snapshot().fullTier.values()) { + if (bucket.observeFullEvaluationData) { + onCount++; + } else { + offCount++; + } + } + assertEquals(1, onCount); + assertEquals(1, offCount); + } + + @Test + void sameConsentEvaluationsForSameSubjectMergeIntoOneBucket() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 1000L, true, emptyMap())); + aggregator.aggregate(event("fold-flag", "on", "alloc1", "user-1", 2000L, true, emptyMap())); + + final FlagEvaluationAggregator.EvalBucket bucket = + aggregator.snapshot().fullTier.values().iterator().next(); + assertEquals(2, bucket.count); + assertTrue(bucket.observeFullEvaluationData); + } + + @Test + void protectedPathCollapsesDifferingContextIntoOneBucket() { + // Same subject, different request-id contexts, consent off: the context is dropped on emit so + // it must not fragment full-tier buckets or the per-flag cap blows out under real traffic. + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + final Map ctx1 = new HashMap<>(); + ctx1.put("request_id", "req-1"); + final Map ctx2 = new HashMap<>(); + ctx2.put("request_id", "req-2"); + final Map ctx3 = new HashMap<>(); + ctx3.put("request_id", "req-3"); + + aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 1000L, false, ctx1)); + aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 2000L, false, ctx2)); + aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 3000L, false, ctx3)); + + assertEquals(1, aggregator.fullTierSize()); + final FlagEvaluationAggregator.EvalBucket bucket = + aggregator.snapshot().fullTier.values().iterator().next(); + assertEquals(3, bucket.count); + assertFalse(bucket.observeFullEvaluationData); + assertEquals(0, bucket.prunedContextFieldCount()); + } + + @Test + void protectedPathSeparatesDifferentSubjects() { + // Different targeting keys must still fall into distinct buckets on the protected path — the + // (hashed) targeting key stays part of the aggregation identity. + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 1000L, false, emptyMap())); + aggregator.aggregate(event("checkout", "on", "alloc1", "bob", 2000L, false, emptyMap())); + + assertEquals(2, aggregator.fullTierSize()); + } + + @Test + void fullPathStillSplitsBucketsOnDifferingContext() { + // Consent-on preserves the previous behaviour: distinct contexts remain distinct buckets so + // each raw context is emitted verbatim. + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + final Map ctx1 = new HashMap<>(); + ctx1.put("plan", "pro"); + ctx1.put("request_id", "req-1"); + final Map ctx2 = new HashMap<>(); + ctx2.put("plan", "pro"); + ctx2.put("request_id", "req-2"); + + aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 1000L, true, ctx1)); + aggregator.aggregate(event("checkout", "on", "alloc1", "alice", 2000L, true, ctx2)); + + assertEquals(2, aggregator.fullTierSize()); + } + + private static FlagEvalEvent event( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final long evalTimeMs, + final boolean observeFullEvaluationData, + final Map attrs) { + return new FlagEvalEvent( + flagKey, + variant, + allocationKey, + targetingKey, + null, + evalTimeMs, + observeFullEvaluationData, + attrs); + } + + private static FlagEvalEvent event( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final long evalTimeMs, + final Map attrs) { + return new FlagEvalEvent(flagKey, variant, allocationKey, targetingKey, evalTimeMs, attrs); + } + + private static FlagEvalEvent simpleEvent(final String flagKey, final String variant) { + return event(flagKey, variant, "alloc1", "user-1", 1000L, emptyMap()); + } + + private static FlagEvaluationAggregator.FullKey fullKey( + final String flagKey, + final String variant, + final String allocationKey, + final boolean runtimeDefaultUsed, + final String errorMessage, + final String targetingKey, + final String contextKey) { + return new FlagEvaluationAggregator.FullKey( + flagKey, + variant, + allocationKey, + runtimeDefaultUsed, + errorMessage, + targetingKey, + contextKey, + false); + } + + private static FlagEvaluationAggregator.DegradedKey degradedKey( + final String flagKey, + final String variant, + final String allocationKey, + final boolean runtimeDefaultUsed, + final String errorMessage) { + return new FlagEvaluationAggregator.DegradedKey( + flagKey, variant, allocationKey, runtimeDefaultUsed, errorMessage); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloadsTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloadsTest.java new file mode 100644 index 00000000000..389e2b3c30f --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloadsTest.java @@ -0,0 +1,430 @@ +package com.datadog.openfeature.internal.flagevaluation; + +import static java.util.Collections.emptyMap; +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 com.datadog.openfeature.internal.JsonReading; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class FlagEvaluationPayloadsTest { + + private static final long EVAL_MS = 1_760_000_000_000L; + private static final Map CONTEXT = context(); + + @Test + void fullTierPayloadUsesWorkerWireShape() throws Exception { + final Map attrs = new HashMap<>(); + attrs.put("region", "us-east-1"); + + final Map json = + firstPayload( + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList( + event("my-flag", "on", "alloc-x", "user-1", 1, attrs)), + CONTEXT, + 1_000_000)); + + final Map ev = firstEvent(json); + assertObjectWithKey(ev.get("variant"), "on"); + assertObjectWithKey(ev.get("allocation"), "alloc-x"); + assertObjectWithKey(ev.get("flag"), "my-flag"); + final Map ctx = (Map) ev.get("context"); + assertNotNull(ctx); + final Map evalAttrs = (Map) ctx.get("evaluation"); + assertNotNull(evalAttrs); + assertEquals("us-east-1", evalAttrs.get("region")); + assertFalse(ev.containsKey("reason")); + } + + @Test + void eventFromFullBucketUsesFlushTimeAndEvaluationBounds() throws Exception { + final FlagEvaluationAggregator.EvalBucket bucket = + new FlagEvaluationAggregator.EvalBucket( + "ts-flag", "on", "alloc1", "user-1", null, EVAL_MS, false, emptyMap(), true); + bucket.merge(EVAL_MS + 10, false); + final long flushTimeMs = EVAL_MS + 5_000; + + final Map json = + firstPayload( + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList( + FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( + bucket, true, true, flushTimeMs)), + CONTEXT, + 1_000_000)); + + final Map ev = firstEvent(json); + assertEquals((double) flushTimeMs, ((Number) ev.get("timestamp")).doubleValue()); + assertEquals((double) EVAL_MS, ((Number) ev.get("first_evaluation")).doubleValue()); + assertEquals((double) (EVAL_MS + 10), ((Number) ev.get("last_evaluation")).doubleValue()); + assertEquals(2.0, ((Number) ev.get("evaluation_count")).doubleValue()); + } + + @Test + void degradedTierEventOmitsTargetingKeyAndContext() throws Exception { + final FlagEvaluationAggregator.EvalBucket bucket = + new FlagEvaluationAggregator.EvalBucket( + "dg-flag", "on", "alloc1", null, null, EVAL_MS, false, null, false); + + final Map json = + firstPayload( + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList( + FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( + bucket, false, true, EVAL_MS)), + CONTEXT, + 1_000_000)); + + final Map ev = firstEvent(json); + assertNull(ev.get("targeting_key")); + assertNull(ev.get("context")); + } + + @Test + void fullTierWithObserveFullEvaluationDataTrueEmitsRawTargetingKeyAndContext() throws Exception { + final Map attrs = new HashMap<>(); + attrs.put("region", "us-east-1"); + final FlagEvaluationAggregator.EvalBucket bucket = + new FlagEvaluationAggregator.EvalBucket( + "pii-flag", + "on", + "alloc1", + "jane.doe@datadoghq.com", + null, + EVAL_MS, + false, + attrs, + true); + + final Map json = + firstPayload( + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList( + FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( + bucket, true, true, EVAL_MS)), + CONTEXT, + 1_000_000)); + + final Map ev = firstEvent(json); + assertEquals("jane.doe@datadoghq.com", ev.get("targeting_key")); + final Map ctx = (Map) ev.get("context"); + assertNotNull(ctx); + final Map evalAttrs = (Map) ctx.get("evaluation"); + assertNotNull(evalAttrs); + assertEquals("us-east-1", evalAttrs.get("region")); + } + + @Test + void fullTierWithObserveFullEvaluationDataFalseHashesTargetingKeyAndOmitsContext() + throws Exception { + final Map attrs = new HashMap<>(); + attrs.put("region", "us-east-1"); + final FlagEvaluationAggregator.EvalBucket bucket = + new FlagEvaluationAggregator.EvalBucket( + "pii-flag", + "on", + "alloc1", + "jane.doe@datadoghq.com", + null, + EVAL_MS, + false, + attrs, + false); + + final FlagEvaluationPayloads.EncodedPayloads payloads = + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList( + FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket( + bucket, true, false, EVAL_MS)), + CONTEXT, + 1_000_000); + final String rawJson = + new String(payloads.bodies.get(0), java.nio.charset.StandardCharsets.UTF_8); + + // The raw wire bytes must carry the hashed key and must not leak the raw PII value or the + // per-event evaluation context — these are the exact properties system-tests asserts over the + // wire. (The batch envelope has its own top-level "context" field, so we guard on the nested + // "evaluation" key instead, which only appears inside a per-event context object.) + assertTrue( + rawJson.contains( + "sha256_b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b")); + assertFalse(rawJson.contains("jane.doe@datadoghq.com")); + assertFalse(rawJson.contains("\"evaluation\":")); + + final Map ev = firstEvent(parse(payloads.bodies.get(0))); + assertEquals( + "sha256_b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b", + ev.get("targeting_key")); + assertFalse(ev.containsKey("context")); + } + + @Test + void splitPayloadsByEncodedSize() throws Exception { + final Map attrs = new HashMap<>(); + attrs.put("payload", repeat('x', 180)); + final java.util.ArrayList events = + new java.util.ArrayList<>(); + for (int i = 0; i < 4; i++) { + events.add(event("split-flag-" + i, "on", "alloc1", "user-" + i, 1, attrs)); + } + + final FlagEvaluationPayloads.EncodedPayloads payloads = + FlagEvaluationPayloads.buildPayloads(events, CONTEXT, 1_100); + + assertTrue(payloads.bodies.size() > 1); + int eventCount = 0; + for (final byte[] body : payloads.bodies) { + assertTrue(body.length <= 1_100); + eventCount += eventCount(parse(body)); + } + assertEquals(4, eventCount); + assertEquals(0, payloads.droppedPayloadLimit); + assertEquals(0, payloads.degradedPayloadLimit); + } + + @Test + void oversizedFullPayloadRowIsDegradedBeforeDrop() throws Exception { + final Map attrs = new HashMap<>(); + for (int i = 0; i < 4; i++) { + attrs.put("payload-" + i, repeat('x', 200)); + } + + final FlagEvaluationPayloads.EncodedPayloads payloads = + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList( + event("oversized-full", "on", "alloc1", "user-1", 2, attrs)), + CONTEXT, + 512); + + assertEquals(1, payloads.bodies.size()); + assertTrue(payloads.bodies.get(0).length <= 512); + assertEquals(0, payloads.droppedPayloadLimit); + assertEquals(2, payloads.degradedPayloadLimit); + final Map ev = firstEvent(parse(payloads.bodies.get(0))); + assertEquals(2.0, ((Number) ev.get("evaluation_count")).doubleValue()); + assertNull(ev.get("targeting_key")); + assertNull(ev.get("context")); + } + + @Test + void oversizedFullPayloadRowStartsNewPayloadWhenDegradedRowFitsByItself() throws Exception { + final FlagEvaluationPayloads.FlagEvaluationEvent first = + event("first-flag", "on", "alloc1", "user-1", 1, emptyMap()); + final Map attrs = new HashMap<>(); + attrs.put("payload", repeat('x', 400)); + final FlagEvaluationPayloads.FlagEvaluationEvent second = + event("second-flag", "on", "alloc1", "user-2", 3, attrs); + final FlagEvaluationPayloads.FlagEvaluationEvent degradedSecond = + second.withoutTargetingKeyAndContext(); + assertNotNull(degradedSecond); + + final int firstPayloadSize = + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList(first), CONTEXT, 1_000_000) + .bodies + .get(0) + .length; + final int degradedPayloadSize = + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList(degradedSecond), CONTEXT, 1_000_000) + .bodies + .get(0) + .length; + final int limit = Math.max(firstPayloadSize, degradedPayloadSize); + + final FlagEvaluationPayloads.EncodedPayloads payloads = + FlagEvaluationPayloads.buildPayloads(Arrays.asList(first, second), CONTEXT, limit); + + assertEquals(2, payloads.bodies.size()); + assertEquals(0, payloads.droppedPayloadLimit); + assertEquals(3, payloads.degradedPayloadLimit); + assertEquals(1, eventCount(parse(payloads.bodies.get(0)))); + final Map ev = firstEvent(parse(payloads.bodies.get(1))); + assertObjectWithKey(ev.get("flag"), "second-flag"); + assertNull(ev.get("targeting_key")); + assertNull(ev.get("context")); + } + + @Test + void oversizedDegradedPayloadRowIsDropped() { + final FlagEvaluationPayloads.EncodedPayloads payloads = + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList( + event(repeat('f', 512), "on", "alloc1", null, 2, emptyMap())), + CONTEXT, + 128); + + assertTrue(payloads.bodies.isEmpty()); + assertEquals(2, payloads.droppedPayloadLimit); + assertEquals(0, payloads.degradedPayloadLimit); + } + + @Test + void oversizedFullPayloadRowIsDroppedWhenDegradedRowStillExceedsLimit() { + final FlagEvaluationPayloads.EncodedPayloads payloads = + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList( + event(repeat('f', 512), "on", "alloc1", "user-1", 2, emptyMap())), + CONTEXT, + 128); + + assertTrue(payloads.bodies.isEmpty()); + assertEquals(2, payloads.droppedPayloadLimit); + assertEquals(0, payloads.degradedPayloadLimit); + } + + @Test + void errorPayloadSerializesErrorObject() throws Exception { + final Map json = + firstPayload( + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList( + new FlagEvaluationPayloads.FlagEvaluationEvent( + EVAL_MS, + "err-flag", + EVAL_MS, + EVAL_MS, + 1, + null, + null, + null, + true, + "type mismatch", + null)), + CONTEXT, + 1_000_000)); + + final Map ev = firstEvent(json); + final Map error = (Map) ev.get("error"); + assertNotNull(error); + assertEquals("type mismatch", error.get("message")); + assertEquals(Boolean.TRUE, ev.get("runtime_default_used")); + } + + @Test + void emptyEventListProducesNoPayloads() { + final FlagEvaluationPayloads.EncodedPayloads payloads = + FlagEvaluationPayloads.buildPayloads(java.util.Collections.emptyList(), CONTEXT, 1_000_000); + + assertTrue(payloads.bodies.isEmpty()); + assertEquals(0, payloads.droppedPayloadLimit); + assertEquals(0, payloads.degradedPayloadLimit); + } + + @Test + void degradingEventWithMissingOptionalFieldsKeepsOptionalObjectsAbsent() { + final FlagEvaluationPayloads.FlagEvaluationEvent degraded = + new FlagEvaluationPayloads.FlagEvaluationEvent( + EVAL_MS, + "default-flag", + EVAL_MS, + EVAL_MS, + 1, + null, + null, + "user-1", + true, + null, + null) + .withoutTargetingKeyAndContext(); + + assertNotNull(degraded); + assertNull(degraded.variant); + assertNull(degraded.allocation); + assertNull(degraded.targeting_key); + assertNull(degraded.context); + assertNull(degraded.error); + assertEquals(Boolean.TRUE, degraded.runtime_default_used); + } + + @Test + void emptyOptionalStringsAreTreatedAsAbsentWhenContextIsPresent() { + final Map attrs = new HashMap<>(); + attrs.put("tier", "gold"); + final FlagEvaluationPayloads.FlagEvaluationEvent event = + new FlagEvaluationPayloads.FlagEvaluationEvent( + EVAL_MS, "empty-optionals", EVAL_MS, EVAL_MS, 1, "", "", null, false, "", attrs); + + assertNull(event.variant); + assertNull(event.allocation); + assertNull(event.error); + assertNotNull(event.context); + + final FlagEvaluationPayloads.FlagEvaluationEvent degraded = + event.withoutTargetingKeyAndContext(); + assertNotNull(degraded); + assertNull(degraded.targeting_key); + assertNull(degraded.context); + } + + private static FlagEvaluationPayloads.FlagEvaluationEvent event( + final String flagKey, + final String variant, + final String allocation, + final String targetingKey, + final long count, + final Map attrs) { + return new FlagEvaluationPayloads.FlagEvaluationEvent( + EVAL_MS, + flagKey, + EVAL_MS, + EVAL_MS, + count, + variant, + allocation, + targetingKey, + false, + null, + attrs); + } + + private static Map firstPayload( + final FlagEvaluationPayloads.EncodedPayloads payloads) throws Exception { + assertEquals(1, payloads.bodies.size()); + return parse(payloads.bodies.get(0)); + } + + private static Map parse(final byte[] body) throws Exception { + return JsonReading.readObjectWithDoubles(body); + } + + @SuppressWarnings("unchecked") + private static Map firstEvent(final Map batch) { + final List events = (List) batch.get("flagEvaluations"); + assertNotNull(events); + assertFalse(events.isEmpty()); + return (Map) events.get(0); + } + + @SuppressWarnings("unchecked") + private static int eventCount(final Map batch) { + final List events = (List) batch.get("flagEvaluations"); + assertNotNull(events); + return events.size(); + } + + private static void assertObjectWithKey(final Object object, final String expectedKey) { + assertTrue(object instanceof Map); + assertEquals(expectedKey, ((Map) object).get("key")); + } + + private static String repeat(final char c, final int count) { + final char[] chars = new char[count]; + java.util.Arrays.fill(chars, c); + return new String(chars); + } + + private static Map context() { + final Map context = new HashMap<>(); + context.put("service", "test-service"); + return context; + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipelineTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipelineTest.java new file mode 100644 index 00000000000..269c37f3b48 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipelineTest.java @@ -0,0 +1,484 @@ +package com.datadog.openfeature.internal.flagevaluation; + +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.buildTestWriter; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.context; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.event; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.eventForFlag; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.flushAndCapture; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.flushAndCaptureJson; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.readJson; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.repeat; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.simpleEvent; +import static java.util.Collections.emptyMap; +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 com.datadog.openfeature.internal.EventQueue; +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.CapturingTransport; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.RecordingMetrics; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.Test; + +class FlagEvaluationPipelineTest { + + private final CapturingTransport transport = new CapturingTransport(); + private final RecordingMetrics metrics = new RecordingMetrics(); + + private FlagEvaluationPipeline writer( + final int capacity, final long flushInterval, final TimeUnit unit) { + return new FlagEvaluationPipeline( + capacity, + flushInterval, + unit, + this.transport, + context(), + new RuntimeServices(this.metrics)); + } + + @Test + void degradedCapOverflowTelemetryIsEmittedOnFlush() { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + + setup.handler.addDroppedDegradedOverflowForTest(3); + setup.handler.flush(); + + assertEquals( + 3, + setup.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationPipeline.DROP_REASON_DEGRADED_CAP)); + } + + @Test + void queueOverflowIncrementsObservableDropCounter() { + final FlagEvaluationPipeline writer = writer(2, 10L, TimeUnit.SECONDS); + + for (int i = 0; i < 100; i++) { + writer.enqueue(simpleEvent("of-flag", "on")); + } + + assertTrue(writer.droppedQueueOverflow() > 0); + final long queueDrops = writer.droppedQueueOverflow(); + writer.flushForTest(); + assertEquals( + queueDrops, + this.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationPipeline.DROP_REASON_QUEUE_OVERFLOW)); + } + + @Test + void enqueueAfterCloseIsDroppedAndCounted() { + final FlagEvaluationPipeline writer = writer(16, Long.MAX_VALUE, TimeUnit.NANOSECONDS); + + writer.close(); + writer.enqueue(simpleEvent("closed-flag", "on")); + + assertEquals( + 1, + this.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationPipeline.DROP_REASON_CLOSED)); + assertNull(writer.pollQueuedEventForTest()); + } + + @Test + void closeSweepsAndCountsEventsLeftInTheQueue() { + final FlagEvaluationPipeline writer = writer(16, Long.MAX_VALUE, TimeUnit.NANOSECONDS); + + // The worker is never started, so nothing drains these; close() must account for them rather + // than leave them silently stranded. Stands in for the narrow window where a lock-free + // producer offers after the worker's final drain. + writer.enqueue(simpleEvent("residual-flag-1", "on")); + writer.enqueue(simpleEvent("residual-flag-2", "on")); + writer.close(); + + assertEquals( + 2, + this.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationPipeline.DROP_REASON_CLOSED)); + assertNull(writer.pollQueuedEventForTest()); + } + + @Test + void enqueueIgnoresNullEvent() { + final FlagEvaluationPipeline writer = writer(16, Long.MAX_VALUE, TimeUnit.NANOSECONDS); + + writer.enqueue(null); + + assertNull(writer.pollQueuedEventForTest()); + assertEquals(0, writer.droppedQueueOverflow()); + } + + @Test + void enqueueDoesNotAggregateOnTheCallingThread() { + final FlagEvaluationPipeline writer = writer(16, Long.MAX_VALUE, TimeUnit.NANOSECONDS); + + writer.enqueue(simpleEvent("g2-flag", "on")); + writer.enqueue(simpleEvent("g2-flag", "on")); + + assertEquals(0, writer.aggregatorFullTierSizeForTest()); + assertEquals(0, writer.droppedQueueOverflow()); + } + + @Test + void flushIfNecessarySkipsEmptyStateAndWaitsForInterval() { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + + setup.handler.flushIfNecessary(); + setup.handler.add(simpleEvent("pending-flag", "on")); + setup.handler.drainAndAggregate(); + setup.handler.flushIfNecessary(); + + assertEquals(1, setup.handler.fullTierSizeForTest()); + } + + @Test + void flushIfNecessaryDoesNotReturnEarlyWhenOnlyQueueDropsArePending() { + final AtomicLong queueDrops = new AtomicLong(1); + final FlagEvaluationPipeline.FlagEvaluationSerializingHandler handler = + new FlagEvaluationPipeline.FlagEvaluationSerializingHandler( + this.transport, + new EventQueue<>(16), + Long.MAX_VALUE, + TimeUnit.NANOSECONDS, + context(), + queueDrops, + new ConcurrentHashMap<>(), + FlagEvaluationPipeline.FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES, + new RuntimeServices(this.metrics)); + + handler.flushIfNecessary(); + + assertEquals(1, queueDrops.get()); + } + + @Test + void degradedBucketsAreSerializedWithoutTargetingKeyOrContext() throws Exception { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + setup.handler.addDegradedBucketForTest("degraded-flag", "on", "alloc1", null, 1000L); + + final Map json = flushAndCaptureJson(setup); + + final Map ev = eventForFlag(json, "degraded-flag"); + assertNotNull(ev); + assertNull(ev.get("targeting_key")); + assertNull(ev.get("context")); + } + + @Test + void testHandlerCanSimulateAndClearDegradedTierAtCap() { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + + setup.handler.simulateDegradedTierAtCap(); + setup.handler.clearAggregationForTest(); + setup.handler.add(simpleEvent("after-clear", "on")); + setup.handler.drainAndAggregate(); + + assertEquals(1, setup.handler.fullTierSizeForTest()); + } + + @Test + void payloadLimitDropsAreCountedOnFlush() { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(128); + setup.handler.add(event(repeat('f', 512), "on", "alloc1", "user-1", 1000L, emptyMap())); + + setup.handler.drainAndAggregate(); + setup.handler.flush(); + + assertEquals( + 1, + setup.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationPipeline.DROP_REASON_PAYLOAD_LIMIT)); + } + + @Test + void finalFlushRunsWithoutTheInterruptFlagSet() throws Exception { + // close() interrupts the worker to break it out of poll(). The final flush does socket I/O, + // which fails fast on an interrupted thread, so the flag must be clear by the time the + // transport is called. + final CountDownLatch posted = new CountDownLatch(1); + final boolean[] interruptedDuringPost = {true}; + this.transport.onPost = + () -> { + interruptedDuringPost[0] = Thread.currentThread().isInterrupted(); + posted.countDown(); + }; + final FlagEvaluationPipeline writer = writer(64, TimeUnit.DAYS.toSeconds(1), TimeUnit.SECONDS); + + writer.start(); + writer.enqueue(simpleEvent("interrupt-flag", "on")); + writer.close(); + + assertTrue(posted.await(5, TimeUnit.SECONDS)); + assertFalse( + interruptedDuringPost[0], + "final flush must not run on an interrupted thread; the drained rows would be lost"); + } + + @Test + void closeDrainsAndFinalFlushesQueuedEvents() throws Exception { + final CountDownLatch posted = new CountDownLatch(1); + this.transport.onPost = posted::countDown; + final FlagEvaluationPipeline writer = writer(64, TimeUnit.DAYS.toSeconds(1), TimeUnit.SECONDS); + + writer.start(); + writer.enqueue(simpleEvent("shutdown-flag", "on")); + writer.close(); + + assertTrue(posted.await(5, TimeUnit.SECONDS)); + final Map json = readJson(this.transport.posts.get(0).body).parsed; + assertNotNull(eventForFlag(json, "shutdown-flag")); + } + + @Test + void continuousTrafficFlushesWithoutWaitingForIdle() throws Exception { + final FlagEvaluationPipeline writer = writer(1 << 12, 1, TimeUnit.MILLISECONDS); + + writer.start(); + boolean posted = false; + try { + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (System.nanoTime() < deadline) { + writer.enqueue(simpleEvent("busy-flag", "on")); + if (!this.transport.posts.isEmpty()) { + posted = true; + break; + } + } + } finally { + writer.close(); + } + + assertTrue(posted); + } + + @Test + void flushPostsToFlagevaluationEndpoint() { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + + setup.handler.add(event("flag-f", "on", "alloc1", "user-1", 1000L, emptyMap())); + setup.handler.drainAndAggregate(); + setup.handler.flush(); + + assertEquals(1, setup.transport.posts.size()); + assertEquals("flagevaluation", setup.transport.posts.get(0).route); + } + + @Test + void splitPostFailureDoesNotRetryAlreadySentPayloads() { + final int limit = 1_100; + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(limit); + setup.transport.failingPost = index -> index == 2; + + for (int i = 0; i < 4; i++) { + final Map attrs = new HashMap<>(); + attrs.put("payload", repeat('x', 180)); + setup.handler.add(event("split-failure-" + i, "on", "alloc1", "user-" + i, 1000L, attrs)); + } + + setup.handler.drainAndAggregate(); + setup.handler.flush(); + assertEquals(2, setup.transport.posts.size()); + setup.handler.flush(); + assertEquals(2, setup.transport.posts.size()); + } + + private static final String HASHED_JANE_DOE = + "sha256_b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b"; + + @Test + void observeFullEvaluationDataTrueEmitsRawTargetingKeyAndContext() throws Exception { + // Consent travels on the event (snapshotted by the hook at evaluation time); the writer honours + // it verbatim. + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + setup.handler.add(piiEvent(true)); + + final Map json = flushAndCapture(setup).parsed; + + final Map ev = eventForFlag(json, "pii-flag"); + assertNotNull(ev); + assertEquals("jane.doe@datadoghq.com", ev.get("targeting_key")); + final Map ctx = (Map) ev.get("context"); + assertNotNull(ctx); + final Map evalAttrs = (Map) ctx.get("evaluation"); + assertNotNull(evalAttrs); + assertEquals("us-east-1", evalAttrs.get("region")); + } + + @Test + void observeFullEvaluationDataFalseHashesTargetingKeyAndOmitsContext() throws Exception { + assertHashedTargetingKeyAndOmittedContext(piiEvent(false)); + } + + @Test + void flagEvalEventDefaultConsentHashesTargetingKeyAndOmitsContext() throws Exception { + // An event built without an explicit consent value defaults to the privacy-preserving false, so + // it must behave exactly like the explicit "false" case. This is the state the hook produces + // when no UFC has been received. + assertHashedTargetingKeyAndOmittedContext(piiEventDefaultConsent()); + } + + @Test + void consentOffPreservesErrorCodeSignalAndNeverLeaksPiiInErrorMessage() throws Exception { + // Upstream contract: the hook substitutes the ErrorCode name for the raw exception message + // under consent-off (see + // FlagEvalLoggingHookTest#errorMessageReplacedByErrorCodeUnderConsentOff). + // This wire-level guard pins that a properly-formed consent-off event (a) still surfaces the + // stable ErrorCode signal for operators and (b) never lets a PII-shaped string escape onto the + // wire. Mirrors the existing PII guards on the targeting_key axis. + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + setup.handler.add( + new FlagEvalEvent( + "err-flag", + null, + "alloc1", + "jane.doe@datadoghq.com", + "TYPE_MISMATCH", + 1000L, + false, + emptyMap())); + + final FlagEvaluationTestSupport.CapturedJson captured = flushAndCapture(setup); + + final Map ev = eventForFlag(captured.parsed, "err-flag"); + assertNotNull(ev); + final Map error = (Map) ev.get("error"); + assertNotNull(error, "error object must be present so operators keep the ErrorCode signal"); + assertEquals("TYPE_MISMATCH", error.get("message")); + assertEquals(HASHED_JANE_DOE, ev.get("targeting_key")); + assertFalse(captured.raw.contains("jane.doe@datadoghq.com")); + assertFalse( + captured.raw.contains("For input string"), + "no exception-message-shaped text may reach the wire under consent-off"); + } + + @Test + void deliveryFailureClearsAggregatorSoLaterFlushesRecover() { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + setup.transport.failingPost = index -> index == 1; + + setup.handler.add(simpleEvent("failed-flag", "on")); + setup.handler.drainAndAggregate(); + setup.handler.flush(); + assertEquals(1, setup.transport.posts.size()); + + // The bucket must not survive the failed flush. A follow-up healthy event flushes cleanly. + setup.handler.add(simpleEvent("healthy-flag", "on")); + setup.handler.drainAndAggregate(); + setup.handler.flush(); + assertEquals(2, setup.transport.posts.size()); + final Map json = readJson(setup.transport.posts.get(1).body).parsed; + assertNull(eventForFlag(json, "failed-flag")); + assertNotNull(eventForFlag(json, "healthy-flag")); + } + + @Test + void nonFiniteContextNumbersAreSerializedAsStrings() { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + final Map attrs = new HashMap<>(); + attrs.put("bad-number", Double.NaN); + setup.handler.add(event("nan-flag", "on", "alloc1", "user-1", 1000L, true, attrs)); + + final FlagEvaluationTestSupport.CapturedJson captured = flushAndCapture(setup); + + assertTrue(captured.raw.contains("\"bad-number\":\"NaN\"")); + } + + @Test + void countContextTruncatedAccumulatesPerReason() { + final FlagEvaluationPipeline writer = writer(16, Long.MAX_VALUE, TimeUnit.NANOSECONDS); + + writer.countContextTruncated("field_count"); + writer.countContextTruncated("field_count"); + writer.countContextTruncated("field_length"); + writer.flushForTest(); + + assertEquals( + 2, + this.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC, "reason:field_count")); + assertEquals( + 1, + this.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC, + "reason:field_length")); + } + + @Test + void hasCapacityForEnqueueReflectsQueueSaturationAndCountsPreQueueOverflow() { + final int capacity = 2; + final FlagEvaluationPipeline writer = writer(capacity, Long.MAX_VALUE, TimeUnit.NANOSECONDS); + + assertTrue(writer.hasCapacityForEnqueue()); + + // Saturate the hand-off queue without starting the worker so no drain can free slots. + for (int i = 0; i < capacity; i++) { + writer.enqueue(simpleEvent("cap-flag-" + i, "on")); + } + assertFalse(writer.hasCapacityForEnqueue()); + + // Simulate a pre-queue overflow account by the hook and surface it on flush. + writer.countPreQueueOverflow(); + writer.flushForTest(); + + assertEquals( + 1, + this.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationPipeline.DROP_REASON_QUEUE_OVERFLOW)); + + writer.close(); + } + + private void assertHashedTargetingKeyAndOmittedContext(final FlagEvalEvent piiEvent) + throws Exception { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + setup.handler.add(piiEvent); + + final FlagEvaluationTestSupport.CapturedJson captured = flushAndCapture(setup); + + final Map ev = eventForFlag(captured.parsed, "pii-flag"); + assertNotNull(ev); + assertEquals(HASHED_JANE_DOE, ev.get("targeting_key")); + assertFalse(ev.containsKey("context")); + // The raw wire bytes must carry the hashed key and never leak the raw PII value or a per-event + // evaluation context (the batch envelope owns the top-level "context" key, so guard on the + // nested "evaluation" field instead). + assertTrue(captured.raw.contains(HASHED_JANE_DOE)); + assertFalse(captured.raw.contains("jane.doe@datadoghq.com")); + assertFalse(captured.raw.contains("\"evaluation\":")); + } + + private static FlagEvalEvent piiEvent(final boolean observeFullEvaluationData) { + return event( + "pii-flag", + "on", + "alloc1", + "jane.doe@datadoghq.com", + 1000L, + observeFullEvaluationData, + piiAttrs()); + } + + private static FlagEvalEvent piiEventDefaultConsent() { + return event("pii-flag", "on", "alloc1", "jane.doe@datadoghq.com", 1000L, piiAttrs()); + } + + private static Map piiAttrs() { + final Map attrs = new HashMap<>(); + attrs.put("region", "us-east-1"); + return attrs; + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationTestSupport.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationTestSupport.java new file mode 100644 index 00000000000..7792035d2d2 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationTestSupport.java @@ -0,0 +1,226 @@ +package com.datadog.openfeature.internal.flagevaluation; + +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.assertTrue; + +import com.datadog.openfeature.internal.JsonReading; +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.IntPredicate; + +final class FlagEvaluationTestSupport { + + static final long REALISTIC_EVAL_MS = 1_760_000_000_000L; + + private FlagEvaluationTestSupport() {} + + static FlagEvalEvent event( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final long evalTimeMs, + final Map attrs) { + return new FlagEvalEvent(flagKey, variant, allocationKey, targetingKey, evalTimeMs, attrs); + } + + static FlagEvalEvent event( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final long evalTimeMs, + final boolean observeFullEvaluationData, + final Map attrs) { + return new FlagEvalEvent( + flagKey, + variant, + allocationKey, + targetingKey, + null, + evalTimeMs, + observeFullEvaluationData, + attrs); + } + + static FlagEvalEvent errorEvent( + final String flagKey, final String errorMessage, final long evalTimeMs) { + return new FlagEvalEvent( + flagKey, null, null, null, errorMessage, evalTimeMs, Collections.emptyMap()); + } + + static FlagEvalEvent simpleEvent(final String flagKey, final String variant) { + return event(flagKey, variant, "alloc1", "user-1", 1000L, Collections.emptyMap()); + } + + static String repeat(final char c, final int count) { + final char[] chars = new char[count]; + Arrays.fill(chars, c); + return new String(chars); + } + + static Map context() { + final Map context = new HashMap<>(); + context.put("service", "test-service"); + return context; + } + + static TestWriterSetup buildTestWriter() { + return buildTestWriter(FlagEvaluationPipeline.FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); + } + + static TestWriterSetup buildTestWriter(final int payloadSizeLimitBytes) { + final CapturingTransport transport = new CapturingTransport(); + final RecordingMetrics metrics = new RecordingMetrics(); + final FlagEvaluationPipeline.SerializingHandlerForTest handler = + new FlagEvaluationPipeline.SerializingHandlerForTest( + transport, context(), payloadSizeLimitBytes, new RuntimeServices(metrics)); + return new TestWriterSetup(handler, transport, metrics); + } + + static CapturedJson flushAndCapture(final TestWriterSetup setup) { + final List captured = flushAndCaptureAll(setup); + assertEquals(1, captured.size(), "Expected exactly one posted payload"); + return captured.get(0); + } + + static List flushAndCaptureAll(final TestWriterSetup setup) { + setup.transport.posts.clear(); + setup.handler.drainAndAggregate(); + setup.handler.flush(); + final List json = new ArrayList<>(); + for (final Post post : setup.transport.posts) { + assertEquals("flagevaluation", post.route); + json.add(readJson(post.body)); + } + return json; + } + + static CapturedJson readJson(final byte[] body) { + assertNotNull(body, "payload must have been posted"); + return new CapturedJson( + new String(body, StandardCharsets.UTF_8), JsonReading.readObjectWithDoubles(body)); + } + + static Map flushAndCaptureJson(final TestWriterSetup setup) { + return flushAndCapture(setup).parsed; + } + + static void assertObjectWithKey(final Object o, final String expectedKey, final String msg) { + assertTrue(o instanceof Map, msg + " (must be a JSON object, not a bare string)"); + assertEquals(expectedKey, ((Map) o).get("key"), msg); + } + + @SuppressWarnings("unchecked") + static Map firstEvent(final Map batch) { + final List events = (List) batch.get("flagEvaluations"); + assertNotNull(events, "flagEvaluations array must be present"); + assertFalse(events.isEmpty(), "flagEvaluations must not be empty"); + return (Map) events.get(0); + } + + @SuppressWarnings("unchecked") + static int eventCount(final Map batch) { + final List events = (List) batch.get("flagEvaluations"); + assertNotNull(events, "flagEvaluations array must be present"); + return events.size(); + } + + @SuppressWarnings("unchecked") + static Map eventForFlag(final Map batch, final String flagKey) { + final List events = (List) batch.get("flagEvaluations"); + for (final Object o : events) { + final Map ev = (Map) o; + final Map flag = (Map) ev.get("flag"); + if (flag != null && flagKey.equals(flag.get("key"))) { + return ev; + } + } + return null; + } + + /** Records the posted payloads, optionally failing some posts. */ + static final class CapturingTransport implements EventTransport { + final List posts = new CopyOnWriteArrayList<>(); + volatile IntPredicate failingPost = index -> false; + volatile Runnable onPost = () -> {}; + + @Override + public void post(final String route, final byte[] json) throws IOException { + this.posts.add(new Post(route, json)); + this.onPost.run(); + if (this.failingPost.test(this.posts.size())) { + throw new IOException("boom"); + } + } + } + + static final class Post { + final String route; + final byte[] body; + + Post(final String route, final byte[] body) { + this.route = route; + this.body = body; + } + } + + /** Records health counters, keyed by their {@code reason:} tag like the agent collector. */ + static final class RecordingMetrics implements HealthMetrics { + private final List counts = new CopyOnWriteArrayList<>(); + + @Override + public void count(final String metric, final long value, final String reason) { + this.counts.add( + new String[] {metric, Long.toString(value), reason == null ? null : "reason:" + reason}); + } + + long sum(final String metricName, final String tag) { + long sum = 0; + for (final String[] count : this.counts) { + if (metricName.equals(count[0]) + && (tag == null ? count[2] == null : tag.equals(count[2]))) { + sum += Long.parseLong(count[1]); + } + } + return sum; + } + } + + static class TestWriterSetup { + final FlagEvaluationPipeline.SerializingHandlerForTest handler; + final CapturingTransport transport; + final RecordingMetrics metrics; + + TestWriterSetup( + final FlagEvaluationPipeline.SerializingHandlerForTest handler, + final CapturingTransport transport, + final RecordingMetrics metrics) { + this.handler = handler; + this.transport = transport; + this.metrics = metrics; + } + } + + static class CapturedJson { + final String raw; + final Map parsed; + + CapturedJson(final String raw, final Map parsed) { + this.raw = raw; + this.parsed = parsed; + } + } +}