From e1dd774d651637886e18781e2a45fac8791a367e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:48:36 -0700 Subject: [PATCH] feat: Suppress individual events and split summary counters for override-affected evaluations The OVERRIDE specification requires an override-affected evaluation to produce no individual feature event and no debug event, whatever the flag requests, while still being counted in summary events under a separate counter that carries the overrideAffected marker. A prerequisite record carries the prerequisite's own marking. EventProcessor gains an overload of recordEvaluationEvent with the marking. It is a default method that delegates to the existing method, so custom implementations keep compiling. The SDK's implementations override it and the evaluator's event recorder passes the evaluation result's marking for top-level, prerequisite, and requested-type mismatch records. Event generation keys on that value alone. The server SDK now depends on launchdarkly-java-sdk-internal 1.13.0, which carries the marker through Event.FeatureRequest, the summarizer, the output formatter, and the event processor. CI for this change cannot pass until that version is released. The specification's vector runner now also asserts the per-evaluation summary marking. Flag overrides are currently experimental and subject to change. --- lib/sdk/server/build.gradle | 2 +- .../server/DefaultEventProcessorWrapper.java | 12 +- .../sdk/server/EvaluationRecorder.java | 3 +- .../sdk/server/InputValidatingEvaluator.java | 24 +- .../sdk/server/NoOpEventProcessor.java | 8 + .../sdk/server/subsystems/EventProcessor.java | 48 ++++ .../server/LDClientOverrideEventsTest.java | 271 ++++++++++++++++++ .../sdk/server/OverrideSpecVectorsTest.java | 25 +- .../sdk/server/TestComponents.java | 11 +- 9 files changed, 391 insertions(+), 13 deletions(-) create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientOverrideEventsTest.java diff --git a/lib/sdk/server/build.gradle b/lib/sdk/server/build.gradle index 399c78df..4a2c1537 100644 --- a/lib/sdk/server/build.gradle +++ b/lib/sdk/server/build.gradle @@ -71,7 +71,7 @@ ext.versions = [ "guava": "32.0.1-jre", "jackson": "2.11.2", "launchdarklyJavaSdkCommon": "2.6.0", - "launchdarklyJavaSdkInternal": "1.11.1", + "launchdarklyJavaSdkInternal": "1.13.0", "launchdarklyLogging": "1.1.0", "okhttp": "4.12.0", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "5.0.0", diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessorWrapper.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessorWrapper.java index 472c4bb6..aa25bf29 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessorWrapper.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessorWrapper.java @@ -34,6 +34,15 @@ public void recordEvaluationEvent(LDContext context, String flagKey, int flagVer LDValue value, EvaluationReason reason, LDValue defaultValue, String prerequisiteOfFlagKey, boolean requireFullEvent, Long debugEventsUntilDate, boolean excludeFromSummaries, Long samplingRatio) { + recordEvaluationEvent(context, flagKey, flagVersion, variation, value, reason, defaultValue, prerequisiteOfFlagKey, + requireFullEvent, debugEventsUntilDate, excludeFromSummaries, samplingRatio, false); + } + + @Override + public void recordEvaluationEvent(LDContext context, String flagKey, int flagVersion, int variation, + LDValue value, EvaluationReason reason, LDValue defaultValue, String prerequisiteOfFlagKey, + boolean requireFullEvent, Long debugEventsUntilDate, boolean excludeFromSummaries, + Long samplingRatio, boolean overrideAffected) { eventProcessor.sendEvent(new Event.FeatureRequest( System.currentTimeMillis(), flagKey, @@ -48,7 +57,8 @@ public void recordEvaluationEvent(LDContext context, String flagKey, int flagVer debugEventsUntilDate, false, samplingRatio != null ? samplingRatio : 1, - excludeFromSummaries + excludeFromSummaries, + overrideAffected )); } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/EvaluationRecorder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/EvaluationRecorder.java index 8dfd1005..75a16342 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/EvaluationRecorder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/EvaluationRecorder.java @@ -16,7 +16,8 @@ default void recordEvaluation(DataModel.FeatureFlag flag, LDContext context, Eva default void recordPrerequisiteEvaluation(DataModel.FeatureFlag flag, DataModel.FeatureFlag prereqOfFlag, LDContext context, EvalResult result) { // default is no op } - default void recordEvaluationError(DataModel.FeatureFlag flag, LDContext context, LDValue defaultValue, EvaluationReason.ErrorKind errorKind) { + default void recordEvaluationError(DataModel.FeatureFlag flag, LDContext context, LDValue defaultValue, + EvaluationReason.ErrorKind errorKind, boolean overrideAffected) { // default is no op } default void recordEvaluationUnknownFlagError(String flagKey, LDContext context, LDValue defaultValue, EvaluationReason.ErrorKind errorKind) { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/InputValidatingEvaluator.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/InputValidatingEvaluator.java index bfebfb98..e08c2a5d 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/InputValidatingEvaluator.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/InputValidatingEvaluator.java @@ -144,7 +144,8 @@ EvalResultAndFlag evaluate(String flagKey, LDContext context, LDValue defaultVal !value.isNull() && value.getType() != requireType) { logger.error("Feature flag \"{}\"; evaluation expected result as {}, but got {}", flagKey, defaultValue.getType(), value.getType()); - recorder.recordEvaluationError(featureFlag, context, defaultValue, ErrorKind.WRONG_TYPE); + recorder.recordEvaluationError(featureFlag, context, defaultValue, ErrorKind.WRONG_TYPE, + result.isOverrideAffected()); // The type mismatch replaces the reason. The evaluation read the same definitions, so the // new reason keeps the override-affected marking. return new EvalResultAndFlag( @@ -163,7 +164,8 @@ EvalResultAndFlag evaluate(String flagKey, LDContext context, LDValue defaultVal if (featureFlag == null) { recorder.recordEvaluationUnknownFlagError(flagKey, context, defaultValue, ErrorKind.EXCEPTION); } else { - recorder.recordEvaluationError(featureFlag, context, defaultValue, ErrorKind.EXCEPTION); + // The evaluation did not complete, so nothing is known about which definitions it read. + recorder.recordEvaluationError(featureFlag, context, defaultValue, ErrorKind.EXCEPTION, false); } return new EvalResultAndFlag(EvalResult.of(defaultValue, NO_VARIATION, EvaluationReason.exception(e)), null); } @@ -255,6 +257,8 @@ private static DataModel.Segment getSegment(ReadOnlyStore store, String key) { */ private static EvaluationRecorder makeEvaluationRecorder(EventProcessor processor, boolean withReasons) { return new EvaluationRecorder() { + // The marking passed to the event processor is the evaluation result's own record of the + // definitions it read. Event generation does not read the evaluation reason. @Override public void recordEvaluation(FeatureFlag flag, LDContext context, EvalResult result, LDValue defaultValue) { processor.recordEvaluationEvent( @@ -269,10 +273,13 @@ public void recordEvaluation(FeatureFlag flag, LDContext context, EvalResult res flag.isTrackEvents() || result.isForceReasonTracking(), flag.getDebugEventsUntilDate(), flag.isExcludeFromSummaries(), - flag.getSamplingRatio() + flag.getSamplingRatio(), + result.isOverrideAffected() ); } + // A prerequisite record carries the prerequisite's own marking, which its own subtree of + // reads set. It is not the marking of the evaluation that requested it. @Override public void recordPrerequisiteEvaluation(FeatureFlag flag, FeatureFlag prereqOfFlag, LDContext context, EvalResult result) { processor.recordEvaluationEvent( @@ -287,25 +294,28 @@ public void recordPrerequisiteEvaluation(FeatureFlag flag, FeatureFlag prereqOfF flag.isTrackEvents() || result.isForceReasonTracking(), flag.getDebugEventsUntilDate(), flag.isExcludeFromSummaries(), - flag.getSamplingRatio() + flag.getSamplingRatio(), + result.isOverrideAffected() ); } @Override - public void recordEvaluationError(FeatureFlag flag, LDContext context, LDValue defaultValue, ErrorKind errorKind) { + public void recordEvaluationError(FeatureFlag flag, LDContext context, LDValue defaultValue, ErrorKind errorKind, + boolean overrideAffected) { processor.recordEvaluationEvent( context, flag.getKey(), flag.getVersion(), NO_VARIATION, defaultValue, - withReasons ? EvaluationReason.error(errorKind) : null, + withReasons ? EvaluationReason.error(errorKind).withOverrideAffected(overrideAffected) : null, defaultValue, null, flag.isTrackEvents(), flag.getDebugEventsUntilDate(), flag.isExcludeFromSummaries(), - flag.getSamplingRatio() + flag.getSamplingRatio(), + overrideAffected ); } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/NoOpEventProcessor.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/NoOpEventProcessor.java index 60684967..37b08ce4 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/NoOpEventProcessor.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/NoOpEventProcessor.java @@ -20,6 +20,14 @@ public void recordEvaluationEvent(LDContext context, String flagKey, int flagVer // no-op } + @Override + public void recordEvaluationEvent(LDContext context, String flagKey, int flagVersion, int variation, LDValue value, + EvaluationReason reason, LDValue defaultValue, String prerequisiteOfFlagKey, + boolean requireFullEvent, Long debugEventsUntilDate, boolean excludeFromSummaries, + Long samplingRatio, boolean overrideAffected) { + // no-op + } + @Override public void recordIdentifyEvent(LDContext context) { // no-op diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/EventProcessor.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/EventProcessor.java index 2381b80f..3fb3a3c2 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/EventProcessor.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/EventProcessor.java @@ -57,6 +57,54 @@ void recordEvaluationEvent( boolean excludeFromSummaries, Long samplingRatio ); + + /** + * Records the action of evaluating a feature flag, with the override-affected marking. + *
+ * An override-affected evaluation is one that read at least one definition from the SDK's + * override store: the flag itself, a prerequisite at any depth, or a segment consulted during + * matching. Such an evaluation produces no individual feature event and no debug event, whatever + * the flag's configuration requests, and it is counted in a separate summary counter that + * carries the marker. The SDK keys this behavior on the marking alone, not on the reason. + *
+ * The default implementation ignores the marking and calls the method without it, so an
+ * existing custom implementation keeps working. Flag overrides are currently experimental and
+ * subject to change.
+ *
+ * @param context the evaluation context
+ * @param flagKey key of the feature flag that was evaluated
+ * @param flagVersion the version of the flag, or {@link #NO_VERSION} if the flag was not found
+ * @param variation the result variation index, or {@link EvaluationDetail#NO_VARIATION} if evaluation failed
+ * @param value the result value
+ * @param reason the evaluation reason, or null if the reason was not requested
+ * @param defaultValue the default value parameter for the evaluation
+ * @param prerequisiteOfFlagKey the key of the flag that this flag was evaluated as a prerequisite of,
+ * or null if this flag was evaluated for itself
+ * @param requireFullEvent true if full-fidelity analytics events should be sent for this flag
+ * @param debugEventsUntilDate if non-null, debug events are to be generated until this millisecond time
+ * @param excludeFromSummaries true if the event evaluation should not be included in summaries
+ * @param samplingRatio ratio used to control event sampling
+ * @param overrideAffected true if an override affected the evaluation
+ * @since 7.18.0
+ */
+ default void recordEvaluationEvent(
+ LDContext context,
+ String flagKey,
+ int flagVersion,
+ int variation,
+ LDValue value,
+ EvaluationReason reason,
+ LDValue defaultValue,
+ String prerequisiteOfFlagKey,
+ boolean requireFullEvent,
+ Long debugEventsUntilDate,
+ boolean excludeFromSummaries,
+ Long samplingRatio,
+ boolean overrideAffected
+ ) {
+ recordEvaluationEvent(context, flagKey, flagVersion, variation, value, reason, defaultValue,
+ prerequisiteOfFlagKey, requireFullEvent, debugEventsUntilDate, excludeFromSummaries, samplingRatio);
+ }
/**
* Registers an evaluation context, as when the SDK's {@code identify} method is called.
diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientOverrideEventsTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientOverrideEventsTest.java
new file mode 100644
index 00000000..a56bf9ae
--- /dev/null
+++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/LDClientOverrideEventsTest.java
@@ -0,0 +1,271 @@
+package com.launchdarkly.sdk.server;
+
+import com.launchdarkly.logging.LDLogLevel;
+import com.launchdarkly.sdk.EvaluationReason;
+import com.launchdarkly.sdk.EvaluationReason.ErrorKind;
+import com.launchdarkly.sdk.LDContext;
+import com.launchdarkly.sdk.LDValue;
+import com.launchdarkly.sdk.internal.events.Event;
+import com.launchdarkly.sdk.server.DataModel.FeatureFlag;
+import com.launchdarkly.sdk.server.DataModel.Segment;
+import com.launchdarkly.sdk.server.TestComponents.TestEventProcessor;
+import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind;
+import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor;
+import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems;
+import com.launchdarkly.sdk.server.subsystems.EventSender;
+
+import org.junit.Test;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+import static com.launchdarkly.sdk.server.ModelBuilders.clauseMatchingSegment;
+import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder;
+import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite;
+import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder;
+import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder;
+import static com.launchdarkly.sdk.server.OverrideLayerTest.collections;
+import static com.launchdarkly.sdk.server.OverrideLayerTest.flagsOnly;
+import static com.launchdarkly.sdk.server.OverrideTestDataSources.initializerWith;
+import static com.launchdarkly.sdk.server.TestComponents.specificComponent;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * The override-affected marking as the client hands it to the event processor, and the resulting
+ * analytics output: no individual feature or debug event for a marked evaluation, and a summary
+ * counter that carries the marker.
+ */
+@SuppressWarnings("javadoc")
+public class LDClientOverrideEventsTest extends BaseTest {
+ private static final String SDK_KEY = "sdk-key";
+ private static final LDContext CONTEXT = LDContext.create("user-key");
+ private static final LDValue DEFAULT = LDValue.of("default");
+
+ private static FeatureFlag valueFlag(String key, int version, String value) {
+ return flagBuilder(key).version(version).on(false).offVariation(0).variations(LDValue.of(value)).build();
+ }
+
+ private LDConfig.Builder baseBuilder(Iterable