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>> ldData, + TestOverrideSource source) { + return new LDConfig.Builder() + .dataSystem(Components.dataSystem().custom().initializers(initializerWith(ldData)).overrides(source)) + .logging(Components.logging(testLogging).level(LDLogLevel.DEBUG)); + } + + private static List featureRecords(TestEventProcessor events, String flagKey) { + List result = new ArrayList<>(); + for (Event e : events.events) { + if (e instanceof Event.FeatureRequest && ((Event.FeatureRequest) e).getKey().equals(flagKey)) { + result.add((Event.FeatureRequest) e); + } + } + return result; + } + + private static Event.FeatureRequest singleRecord(TestEventProcessor events, String flagKey) { + List records = featureRecords(events, flagKey); + assertEquals("expected one record for " + flagKey, 1, records.size()); + return records.get(0); + } + + @Test + public void evaluationOfOverriddenFlagIsRecordedAsOverrideAffected() throws Exception { + TestEventProcessor events = new TestEventProcessor(); + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("overridden", 5, "override-value"))); + Iterable>> ldData = flagsOnly( + valueFlag("overridden", 1, "ld-value"), valueFlag("plain", 1, "plain-value")); + + try (LDClient client = new LDClient(SDK_KEY, baseBuilder(ldData, source).events(specificComponent(events)).build())) { + client.stringVariation("overridden", CONTEXT, "default"); + client.stringVariation("plain", CONTEXT, "default"); + + Event.FeatureRequest overridden = singleRecord(events, "overridden"); + assertTrue(overridden.isOverrideAffected()); + assertEquals(5, overridden.getVersion()); + assertEquals(LDValue.of("override-value"), overridden.getValue()); + + Event.FeatureRequest plain = singleRecord(events, "plain"); + assertFalse(plain.isOverrideAffected()); + } + } + + @Test + public void recordedMarkingDoesNotDependOnRequestingTheReason() throws Exception { + TestEventProcessor events = new TestEventProcessor(); + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("overridden", 5, "override-value"))); + + try (LDClient client = new LDClient(SDK_KEY, baseBuilder(flagsOnly(), source).events(specificComponent(events)).build())) { + client.stringVariation("overridden", CONTEXT, "default"); + Event.FeatureRequest record = singleRecord(events, "overridden"); + assertTrue(record.isOverrideAffected()); + assertNull(record.getReason()); + } + } + + @Test + public void prerequisiteRecordsCarryTheirOwnMarking() throws Exception { + TestEventProcessor events = new TestEventProcessor(); + FeatureFlag parent = flagBuilder("parent").version(1).on(true).fallthroughVariation(1).offVariation(0) + .variations(LDValue.of("off"), LDValue.of("on")) + .prerequisites(prerequisite("overridden-prereq", 1), prerequisite("plain-prereq", 1)).build(); + FeatureFlag ldOverriddenPrereq = flagBuilder("overridden-prereq").version(1).on(false).offVariation(0) + .variations(LDValue.of("a"), LDValue.of("b")).build(); + FeatureFlag plainPrereq = flagBuilder("plain-prereq").version(1).on(true).fallthroughVariation(1).offVariation(0) + .variations(LDValue.of("a"), LDValue.of("b")).build(); + FeatureFlag overriddenPrereq = flagBuilder("overridden-prereq").version(2).on(true).fallthroughVariation(1) + .offVariation(0).variations(LDValue.of("a"), LDValue.of("b")).build(); + TestOverrideSource source = new TestOverrideSource(flagsOnly(overriddenPrereq)); + + try (LDClient client = new LDClient(SDK_KEY, + baseBuilder(flagsOnly(parent, ldOverriddenPrereq, plainPrereq), source).events(specificComponent(events)).build())) { + assertEquals("on", client.stringVariation("parent", CONTEXT, "default")); + + assertTrue(singleRecord(events, "parent").isOverrideAffected()); + Event.FeatureRequest overriddenRecord = singleRecord(events, "overridden-prereq"); + assertTrue(overriddenRecord.isOverrideAffected()); + assertEquals("parent", overriddenRecord.getPrereqOf()); + assertEquals(2, overriddenRecord.getVersion()); + Event.FeatureRequest plainRecord = singleRecord(events, "plain-prereq"); + assertFalse(plainRecord.isOverrideAffected()); + assertEquals("parent", plainRecord.getPrereqOf()); + } + } + + @Test + public void segmentReadMarksTheRecord() throws Exception { + TestEventProcessor events = new TestEventProcessor(); + FeatureFlag usesSegment = flagBuilder("uses-segment").version(1).on(true).fallthroughVariation(0).offVariation(0) + .variations(LDValue.of("no"), LDValue.of("yes")) + .rules(ruleBuilder().id("r").variation(1).clauses(clauseMatchingSegment("seg")).build()).build(); + Segment ldSegment = segmentBuilder("seg").version(1).build(); + Segment overrideSegment = segmentBuilder("seg").version(2).build(); // still does not include the context + TestOverrideSource source = new TestOverrideSource( + collections(Collections.emptyList(), Collections.singletonList(overrideSegment))); + + try (LDClient client = new LDClient(SDK_KEY, baseBuilder( + collections(Collections.singletonList(usesSegment), Collections.singletonList(ldSegment)), source) + .events(specificComponent(events)).build())) { + assertEquals("no", client.stringVariation("uses-segment", CONTEXT, "default")); + assertTrue(singleRecord(events, "uses-segment").isOverrideAffected()); + } + } + + @Test + public void typeMismatchRecordKeepsMarking() throws Exception { + TestEventProcessor events = new TestEventProcessor(); + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("overridden", 5, "a string"))); + + try (LDClient client = new LDClient(SDK_KEY, baseBuilder(flagsOnly(), source).events(specificComponent(events)).build())) { + assertTrue(client.boolVariation("overridden", CONTEXT, true)); + Event.FeatureRequest record = singleRecord(events, "overridden"); + assertTrue(record.isOverrideAffected()); + assertEquals(EvaluationReason.error(ErrorKind.WRONG_TYPE).withOverrideAffected(true), + client.boolVariationDetail("overridden", CONTEXT, true).getReason()); + } + } + + @Test + public void unknownFlagRecordIsNotMarked() throws Exception { + TestEventProcessor events = new TestEventProcessor(); + TestOverrideSource source = new TestOverrideSource(flagsOnly(valueFlag("overridden", 5, "v"))); + try (LDClient client = new LDClient(SDK_KEY, baseBuilder(flagsOnly(), source).events(specificComponent(events)).build())) { + client.stringVariation("missing", CONTEXT, "default"); + assertFalse(singleRecord(events, "missing").isOverrideAffected()); + } + } + + /** + * Captures the analytics payloads that the default event processor sends. + */ + private static final class CapturingEventSender implements EventSender { + final BlockingQueue payloads = new LinkedBlockingQueue<>(); + + @Override + public Result sendAnalyticsEvents(byte[] data, int eventCount, URI eventsBaseUri) { + payloads.add(LDValue.parse(new String(data, StandardCharsets.UTF_8))); + return Result.SUCCESS; + } + + @Override + public Result sendDiagnosticEvent(byte[] data, URI eventsBaseUri) { + return Result.SUCCESS; + } + + @Override + public void close() { + } + } + + private static List eventsOfKind(LDValue payload, String kind) { + List result = new ArrayList<>(); + for (LDValue e : payload.values()) { + if (e.get("kind").stringValue().equals(kind)) { + result.add(e); + } + } + return result; + } + + @Test + public void overrideAffectedEvaluationsAppearOnlyInSummaryOutput() throws Exception { + long debugUntil = System.currentTimeMillis() + 100000; + // Both flags request individual feature events and debug events. + FeatureFlag overridden = flagBuilder("overridden").version(300).on(false).offVariation(0) + .variations(LDValue.of("override-value")).trackEvents(true).debugEventsUntilDate(debugUntil).build(); + FeatureFlag plain = flagBuilder("plain").version(100).on(false).offVariation(0) + .variations(LDValue.of("plain-value")).trackEvents(true).debugEventsUntilDate(debugUntil).build(); + TestOverrideSource source = new TestOverrideSource(flagsOnly(overridden)); + CapturingEventSender sender = new CapturingEventSender(); + + LDConfig config = baseBuilder(flagsOnly(plain), source) + .events(Components.sendEvents().eventSender(specificComponent(sender)).flushInterval(Duration.ofHours(1))) + .diagnosticOptOut(true) + .build(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + client.stringVariation("overridden", CONTEXT, "default"); + client.stringVariation("overridden", CONTEXT, "default"); + client.stringVariation("plain", CONTEXT, "default"); + client.flush(); + + LDValue payload = sender.payloads.poll(5, TimeUnit.SECONDS); + assertNotNull("no analytics payload was sent", payload); + + // The overridden flag produced no feature event and no debug event. The plain flag produced both. + List featureEvents = eventsOfKind(payload, "feature"); + List debugEvents = eventsOfKind(payload, "debug"); + assertEquals(1, featureEvents.size()); + assertEquals("plain", featureEvents.get(0).get("key").stringValue()); + assertEquals(1, debugEvents.size()); + assertEquals("plain", debugEvents.get(0).get("key").stringValue()); + assertEquals(1, eventsOfKind(payload, "index").size()); + + // Both flags are summarized. Only the overridden flag's counter carries the marker. + List summaries = eventsOfKind(payload, "summary"); + assertEquals(1, summaries.size()); + LDValue features = summaries.get(0).get("features"); + LDValue overriddenCounters = features.get("overridden").get("counters"); + assertEquals(1, overriddenCounters.size()); + LDValue overriddenCounter = overriddenCounters.get(0); + assertTrue(overriddenCounter.get("overrideAffected").booleanValue()); + assertEquals(300, overriddenCounter.get("version").intValue()); + assertEquals(2, overriddenCounter.get("count").intValue()); + assertEquals(LDValue.of("override-value"), overriddenCounter.get("value")); + LDValue plainCounter = features.get("plain").get("counters").get(0); + assertTrue(plainCounter.get("overrideAffected").isNull()); + assertEquals(1, plainCounter.get("count").intValue()); + } + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideSpecVectorsTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideSpecVectorsTest.java index 467a362e..0e3b1f85 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideSpecVectorsTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/OverrideSpecVectorsTest.java @@ -4,7 +4,9 @@ import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.internal.events.Event; import com.launchdarkly.sdk.json.JsonSerialization; +import com.launchdarkly.sdk.server.TestComponents.TestEventProcessor; import com.launchdarkly.sdk.server.integrations.DataSystemBuilder; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; @@ -25,6 +27,7 @@ import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static com.launchdarkly.sdk.server.OverrideTestDataSources.hangingSynchronizer; 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.assertTrue; @@ -32,7 +35,8 @@ /** * Runs the OVERRIDE specification's test vectors. Each vector sets up LaunchDarkly data, an * override layer, and an initialization state, evaluates one flag through the full client stack, - * and checks the value, the variation index, and the reason. + * and checks the value, the variation index, the reason, and the marking that the client hands to the + * event processor for the evaluation. */ @SuppressWarnings("javadoc") public class OverrideSpecVectorsTest extends BaseTest { @@ -104,8 +108,9 @@ private void runVector(LDValue vector) throws Exception { overrides.get("flags"), overrides.get("flagValues"), overrides.get("segments"))); DataSystemBuilder dataSystem = Components.dataSystem().custom().overrides(source); + TestEventProcessor events = new TestEventProcessor(); LDConfig.Builder config = new LDConfig.Builder() - .events(Components.noEvents()) + .events(specificComponent(events)) .logging(Components.logging(testLogging).level(LDLogLevel.DEBUG)); if (initialized) { dataSystem.initializers(initializerWith(collectionsFromJson( @@ -135,6 +140,22 @@ private void runVector(LDValue vector) throws Exception { assertEquals("variationIndex", expect.get("variationIndex").intValue(), detail.getVariationIndex()); } assertVectorReason(expect.get("reason"), LDValue.parse(JsonSerialization.serialize(detail.getReason()))); + + // summaryOverrideAffected is the marking the client hands to the event processor for this + // evaluation. The event processor keys individual event suppression and the summary counter + // marker on that value, not on the reason. + if (!expect.get("summaryOverrideAffected").isNull()) { + List records = new ArrayList<>(); + for (Event e : events.events) { + if (e instanceof Event.FeatureRequest + && ((Event.FeatureRequest) e).getKey().equals(evaluate.get("flagKey").stringValue())) { + records.add((Event.FeatureRequest) e); + } + } + assertEquals("expected exactly one evaluation record for the flag", 1, records.size()); + assertEquals("summaryOverrideAffected", expect.get("summaryOverrideAffected").booleanValue(), + records.get(0).isOverrideAffected()); + } } } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/TestComponents.java index 77b4d699..5bc8d51a 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/TestComponents.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -149,8 +149,17 @@ public void recordEvaluationEvent(LDContext context, String flagKey, int flagVer 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) { events.add(new Event.FeatureRequest(System.currentTimeMillis(), flagKey, context, flagVersion, - variation, value, defaultValue, reason, prerequisiteOfFlagKey, requireFullEvent, debugEventsUntilDate, false)); + variation, value, defaultValue, reason, prerequisiteOfFlagKey, requireFullEvent, debugEventsUntilDate, false, + samplingRatio == null ? 1 : samplingRatio, excludeFromSummaries, overrideAffected)); } @Override