From bd8471b8bf06103bf0c11b0aca99cab985c6964c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:21:39 -0800 Subject: [PATCH 1/5] feat: Add optional support for per-context summary events. --- .../events/DefaultEventProcessor.java | 89 +++++++++++++----- .../internal/events/EventOutputFormatter.java | 16 +++- .../sdk/internal/events/EventSummarizer.java | 26 ++++-- .../internal/events/EventsConfiguration.java | 43 ++++++++- .../events/MultiContextEventSummarizer.java | 93 +++++++++++++++++++ .../sdk/internal/events/EventOutputTest.java | 7 +- 6 files changed, 238 insertions(+), 36 deletions(-) create mode 100644 lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/MultiContextEventSummarizer.java diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java index 1ad7a02f..c9da3835 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java @@ -363,7 +363,7 @@ public Thread newThread(Runnable r) { // all the workers are busy. final BlockingQueue payloadQueue = new ArrayBlockingQueue<>(1); - final EventBuffer outbox = new EventBuffer(eventsConfig.capacity, logger); + final EventBuffer outbox = new EventBuffer(eventsConfig.capacity, eventsConfig.perContextSummarization, logger); this.contextDeduplicator = eventsConfig.contextDeduplicator; Thread mainThread = threadFactory.newThread(new Thread() { @@ -608,7 +608,13 @@ private void triggerFlush(EventBuffer outbox, BlockingQueue payloa } FlushPayload payload = outbox.getPayload(); if (diagnosticStore != null) { - int eventCount = payload.events.length + (payload.summary.isEmpty() ? 0 : 1); + int summaryCount = 0; + for (EventSummary summary : payload.summaries) { + if (!summary.isEmpty()) { + summaryCount++; + } + } + int eventCount = payload.events.length + summaryCount; diagnosticStore.recordEventsInBatch(eventCount); } busyFlushWorkersCount.incrementAndGet(); @@ -618,7 +624,10 @@ private void triggerFlush(EventBuffer outbox, BlockingQueue payloa } else { logger.debug("Skipped flushing because all workers are busy"); // All the workers are busy so we can't flush now; keep the events in our state - outbox.summarizer.restoreTo(payload.summary); + // Only restore if using single summarizer (not per-context) + if (outbox.summarizer != null && !payload.summaries.isEmpty()) { + outbox.summarizer.restoreTo(payload.summaries.get(0)); + } synchronized(busyFlushWorkersCount) { busyFlushWorkersCount.decrementAndGet(); busyFlushWorkersCount.notify(); @@ -661,15 +670,25 @@ public void run() { private static final class EventBuffer { final List events = new ArrayList<>(); - final EventSummarizer summarizer = new EventSummarizer(); + final EventSummarizer summarizer; // used when perContextSummarization is false + final MultiContextEventSummarizer multiContextSummarizer; // used when perContextSummarization is true + private final boolean perContextSummarization; private final int capacity; private final LDLogger logger; private boolean capacityExceeded = false; private long droppedEventCount = 0; - EventBuffer(int capacity, LDLogger logger) { + EventBuffer(int capacity, boolean perContextSummarization, LDLogger logger) { this.capacity = capacity; + this.perContextSummarization = perContextSummarization; this.logger = logger; + if (perContextSummarization) { + this.summarizer = null; + this.multiContextSummarizer = new MultiContextEventSummarizer(); + } else { + this.summarizer = new EventSummarizer(); + this.multiContextSummarizer = null; + } } void add(Event e) { @@ -686,19 +705,35 @@ void add(Event e) { } void addToSummary(Event.FeatureRequest e) { - summarizer.summarizeEvent( - e.getCreationDate(), - e.getKey(), - e.getVersion(), - e.getVariation(), - e.getValue(), - e.getDefaultVal(), - e.getContext() - ); + if (perContextSummarization) { + multiContextSummarizer.summarizeEvent( + e.getCreationDate(), + e.getKey(), + e.getVersion(), + e.getVariation(), + e.getValue(), + e.getDefaultVal(), + e.getContext() + ); + } else { + summarizer.summarizeEvent( + e.getCreationDate(), + e.getKey(), + e.getVersion(), + e.getVariation(), + e.getValue(), + e.getDefaultVal(), + e.getContext() + ); + } } boolean isEmpty() { - return events.isEmpty() && summarizer.isEmpty(); + if (perContextSummarization) { + return events.isEmpty() && multiContextSummarizer.isEmpty(); + } else { + return events.isEmpty() && summarizer.isEmpty(); + } } long getAndClearDroppedCount() { @@ -709,23 +744,33 @@ long getAndClearDroppedCount() { FlushPayload getPayload() { Event[] eventsOut = events.toArray(new Event[events.size()]); - EventSummarizer.EventSummary summary = summarizer.getSummaryAndReset(); - return new FlushPayload(eventsOut, summary); + List summaries; + if (perContextSummarization) { + summaries = multiContextSummarizer.getSummariesAndReset(); + } else { + EventSummarizer.EventSummary summary = summarizer.getSummaryAndReset(); + summaries = java.util.Collections.singletonList(summary); + } + return new FlushPayload(eventsOut, summaries); } void clear() { events.clear(); - summarizer.clear(); + if (perContextSummarization) { + multiContextSummarizer.clear(); + } else { + summarizer.clear(); + } } } private static final class FlushPayload { final Event[] events; - final EventSummary summary; + final List summaries; - FlushPayload(Event[] events, EventSummary summary) { + FlushPayload(Event[] events, List summaries) { this.events = events; - this.summary = summary; + this.summaries = summaries; } } @@ -774,7 +819,7 @@ public void run() { try { ByteArrayOutputStream buffer = new ByteArrayOutputStream(INITIAL_OUTPUT_BUFFER_SIZE); Writer writer = new BufferedWriter(new OutputStreamWriter(buffer, Charset.forName("UTF-8")), INITIAL_OUTPUT_BUFFER_SIZE); - int outputEventCount = formatter.writeOutputEvents(payload.events, payload.summary, writer); + int outputEventCount = formatter.writeOutputEvents(payload.events, payload.summaries, writer); writer.flush(); EventSender.Result result = eventsConfig.eventSender.sendAnalyticsEvents( buffer.toByteArray(), diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java index feab4234..185eba59 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java @@ -11,6 +11,7 @@ import java.io.IOException; import java.io.Writer; +import java.util.List; import java.util.Map; import static com.launchdarkly.sdk.internal.GsonHelpers.gsonInstance; @@ -33,7 +34,7 @@ final class EventOutputFormatter { config.privateAttributes.toArray(new AttributeRef[config.privateAttributes.size()])); } - int writeOutputEvents(Event[] events, EventSummarizer.EventSummary summary, Writer writer) throws IOException { + int writeOutputEvents(Event[] events, List summaries, Writer writer) throws IOException { int count = 0; JsonWriter jsonWriter = new JsonWriter(writer); jsonWriter.beginArray(); @@ -42,9 +43,11 @@ int writeOutputEvents(Event[] events, EventSummarizer.EventSummary summary, Writ count++; } } - if (!summary.isEmpty()) { - writeSummaryEvent(summary, jsonWriter); - count++; + for (EventSummarizer.EventSummary summary : summaries) { + if (!summary.isEmpty()) { + writeSummaryEvent(summary, jsonWriter); + count++; + } } jsonWriter.endArray(); jsonWriter.flush(); @@ -234,6 +237,11 @@ private void writeSummaryEvent(EventSummarizer.EventSummary summary, JsonWriter jw.name("endDate"); jw.value(summary.endDate); + // Include context if present (per-context summarization) + if (summary.context != null) { + writeContext(summary.context, jw, true); // redact anonymous attributes + } + jw.name("features"); jw.beginObject(); diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventSummarizer.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventSummarizer.java index 9eaef83b..3b942d7e 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventSummarizer.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventSummarizer.java @@ -16,9 +16,15 @@ */ final class EventSummarizer { private EventSummary eventsState; - + private final LDContext context; // nullable - only set for per-context summarization + EventSummarizer() { - this.eventsState = new EventSummary(); + this(null); + } + + EventSummarizer(LDContext context) { + this.context = context; + this.eventsState = new EventSummary(context); } /** @@ -76,22 +82,29 @@ boolean isEmpty() { } void clear() { - eventsState = new EventSummary(); + eventsState = new EventSummary(context); } static final class EventSummary { final Map counters; long startDate; long endDate; - + final LDContext context; // nullable for backward compatibility + EventSummary() { - counters = new HashMap<>(); + this((LDContext) null); + } + + EventSummary(LDContext context) { + this.counters = new HashMap<>(); + this.context = context; } EventSummary(EventSummary from) { counters = new HashMap<>(from.counters); startDate = from.startDate; endDate = from.endDate; + context = from.context; } boolean isEmpty() { @@ -142,7 +155,8 @@ void noteTimestamp(long time) { public boolean equals(Object other) { if (other instanceof EventSummary) { EventSummary o = (EventSummary)other; - return o.counters.equals(counters) && startDate == o.startDate && endDate == o.endDate; + return o.counters.equals(counters) && startDate == o.startDate && endDate == o.endDate && + Objects.equals(context, o.context); } return false; } diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java index c0c20888..c4474017 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java @@ -30,10 +30,11 @@ public final class EventsConfiguration { final boolean initiallyInBackground; final boolean initiallyOffline; final List privateAttributes; + final boolean perContextSummarization; /** * Creates an instance. - * + * * @param allAttributesPrivate true if all attributes are private * @param capacity event buffer capacity (if zero or negative, a value of 1 is used to prevent errors) * @param contextDeduplicator optional EventContextDeduplicator; null for client-side SDK @@ -63,6 +64,45 @@ public EventsConfiguration( boolean initiallyOffline, Collection privateAttributes ) { + this(allAttributesPrivate, capacity, contextDeduplicator, diagnosticRecordingIntervalMillis, + diagnosticStore, eventSender, eventSendingThreadPoolSize, eventsUri, flushIntervalMillis, + initiallyInBackground, initiallyOffline, privateAttributes, false); + } + + /** + * Creates an instance. + * + * @param allAttributesPrivate true if all attributes are private + * @param capacity event buffer capacity (if zero or negative, a value of 1 is used to prevent errors) + * @param contextDeduplicator optional EventContextDeduplicator; null for client-side SDK + * @param diagnosticRecordingIntervalMillis diagnostic recording interval + * @param diagnosticStore optional DiagnosticStore; null if diagnostics are disabled + * @param eventSender event delivery component; must not be null + * @param eventSendingThreadPoolSize number of worker threads for event delivery; zero to use the default + * @param eventsUri events base URI + * @param flushIntervalMillis event flush interval + * @param initiallyInBackground true if we should start out in background mode (see + * {@link DefaultEventProcessor#setInBackground(boolean)}) + * @param initiallyOffline true if we should start out in offline mode (see + * {@link DefaultEventProcessor#setOffline(boolean)}) + * @param privateAttributes list of private attribute references; may be null + * @param perContextSummarization true to generate separate summary events per context + */ + public EventsConfiguration( + boolean allAttributesPrivate, + int capacity, + EventContextDeduplicator contextDeduplicator, + long diagnosticRecordingIntervalMillis, + DiagnosticStore diagnosticStore, + EventSender eventSender, + int eventSendingThreadPoolSize, + URI eventsUri, + long flushIntervalMillis, + boolean initiallyInBackground, + boolean initiallyOffline, + Collection privateAttributes, + boolean perContextSummarization + ) { super(); this.allAttributesPrivate = allAttributesPrivate; this.capacity = capacity >= 0 ? capacity : 1; @@ -77,5 +117,6 @@ public EventsConfiguration( this.initiallyInBackground = initiallyInBackground; this.initiallyOffline = initiallyOffline; this.privateAttributes = privateAttributes == null ? Collections.emptyList() : new ArrayList<>(privateAttributes); + this.perContextSummarization = perContextSummarization; } } \ No newline at end of file diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/MultiContextEventSummarizer.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/MultiContextEventSummarizer.java new file mode 100644 index 00000000..87e24219 --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/MultiContextEventSummarizer.java @@ -0,0 +1,93 @@ +package com.launchdarkly.sdk.internal.events; + +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Manages per-context event summarization. Maintains separate EventSummarizer instances + * for each unique context, allowing generation of multiple summary events per flush interval. + *

+ * Note that the methods of this class are deliberately not thread-safe, because they should + * always be called from EventProcessor's single message-processing thread. + */ +final class MultiContextEventSummarizer { + private final Map summarizersByContext; + + MultiContextEventSummarizer() { + this.summarizersByContext = new HashMap<>(); + } + + /** + * Adds information about an evaluation to the appropriate context's summarizer. + * + * @param timestamp the millisecond timestamp + * @param flagKey the flag key + * @param flagVersion the flag version, or -1 if the flag is unknown + * @param variation the result variation, or -1 if none + * @param value the result value + * @param defaultValue the application default value + * @param context the evaluation context + */ + void summarizeEvent( + long timestamp, + String flagKey, + int flagVersion, + int variation, + LDValue value, + LDValue defaultValue, + LDContext context + ) { + // Get or create summarizer for this context + EventSummarizer summarizer = summarizersByContext.get(context); + if (summarizer == null) { + summarizer = new EventSummarizer(context); + summarizersByContext.put(context, summarizer); + } + + // Delegate to the per-context summarizer + summarizer.summarizeEvent(timestamp, flagKey, flagVersion, variation, value, defaultValue, context); + } + + /** + * Gets all current summarized event data (one per context), and resets the state to empty. + * + * @return list of summary states, one for each context that had events + */ + List getSummariesAndReset() { + List summaries = new ArrayList<>(); + for (EventSummarizer summarizer : summarizersByContext.values()) { + EventSummarizer.EventSummary summary = summarizer.getSummaryAndReset(); + if (!summary.isEmpty()) { + summaries.add(summary); + } + } + summarizersByContext.clear(); + return summaries; + } + + /** + * Returns true if there is no summary data for any context. + * + * @return true if all contexts have empty state + */ + boolean isEmpty() { + for (EventSummarizer summarizer : summarizersByContext.values()) { + if (!summarizer.isEmpty()) { + return false; + } + } + return true; + } + + /** + * Clears all summarizers and context tracking. + */ + void clear() { + summarizersByContext.clear(); + } +} diff --git a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java index f51e00dd..d10c23ac 100644 --- a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java +++ b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java @@ -16,6 +16,7 @@ import java.io.IOException; import java.io.StringWriter; +import java.util.Collections; import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; import static org.hamcrest.MatcherAssert.assertThat; @@ -315,7 +316,7 @@ public void summaryEventIsSerialized() throws Exception { EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); StringWriter w = new StringWriter(); - int count = f.writeOutputEvents(new Event[0], summary, w); + int count = f.writeOutputEvents(new Event[0], Collections.singletonList(summary), w); assertEquals(1, count); LDValue outputEvent = parseValue(w.toString()).get(0); @@ -667,7 +668,7 @@ public void unknownEventClassIsNotSerialized() throws Exception { EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); StringWriter w = new StringWriter(); - f.writeOutputEvents(new Event[] { event }, new EventSummary(), w); + f.writeOutputEvents(new Event[] { event }, Collections.singletonList(new EventSummary()), w); assertEquals("[]", w.toString()); } @@ -684,7 +685,7 @@ private static LDValue parseValue(String json) { private LDValue getSingleOutputEvent(EventOutputFormatter f, Event event) throws IOException { StringWriter w = new StringWriter(); - int count = f.writeOutputEvents(new Event[] { event }, new EventSummary(), w); + int count = f.writeOutputEvents(new Event[] { event }, Collections.singletonList(new EventSummary()), w); assertEquals(1, count); return parseValue(w.toString()).get(0); } From a2339e127235f4eea7637b3d58cea1542ea9d0ba Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:49:14 -0800 Subject: [PATCH 2/5] Adjustments to testing and failed flush behavior. --- .../events/DefaultEventProcessor.java | 13 +- .../events/MultiContextEventSummarizer.java | 17 + .../sdk/internal/events/BaseEventTest.java | 9 +- .../MultiContextEventSummarizerTest.java | 385 ++++++++++++++++++ 4 files changed, 420 insertions(+), 4 deletions(-) create mode 100644 lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/MultiContextEventSummarizerTest.java diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java index c9da3835..d990f6e8 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java @@ -624,9 +624,16 @@ private void triggerFlush(EventBuffer outbox, BlockingQueue payloa } else { logger.debug("Skipped flushing because all workers are busy"); // All the workers are busy so we can't flush now; keep the events in our state - // Only restore if using single summarizer (not per-context) - if (outbox.summarizer != null && !payload.summaries.isEmpty()) { - outbox.summarizer.restoreTo(payload.summaries.get(0)); + if (outbox.perContextSummarization) { + // Per-context mode: restore all summaries + if (outbox.multiContextSummarizer != null && !payload.summaries.isEmpty()) { + outbox.multiContextSummarizer.restoreTo(payload.summaries); + } + } else { + // Single summary mode: restore single summary + if (outbox.summarizer != null && !payload.summaries.isEmpty()) { + outbox.summarizer.restoreTo(payload.summaries.get(0)); + } } synchronized(busyFlushWorkersCount) { busyFlushWorkersCount.decrementAndGet(); diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/MultiContextEventSummarizer.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/MultiContextEventSummarizer.java index 87e24219..f2cb3371 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/MultiContextEventSummarizer.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/MultiContextEventSummarizer.java @@ -70,6 +70,23 @@ List getSummariesAndReset() { return summaries; } + /** + * Restores the summarizer state from a previous snapshot. This is used when a flush + * operation fails and we need to keep the summary data for the next attempt. + * + * @param previousSummaries the list of summaries to restore + */ + void restoreTo(List previousSummaries) { + summarizersByContext.clear(); + for (EventSummarizer.EventSummary summary : previousSummaries) { + if (summary.context != null && !summary.isEmpty()) { + EventSummarizer summarizer = new EventSummarizer(summary.context); + summarizer.restoreTo(summary); + summarizersByContext.put(summary.context, summarizer); + } + } + } + /** * Returns true if there is no summary data for any context. * diff --git a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java index ef68f501..18fadac6 100644 --- a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java +++ b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java @@ -333,6 +333,7 @@ public static class EventsConfigurationBuilder { private boolean initiallyInBackground = false; private boolean initiallyOffline = false; private Set privateAttributes = new HashSet<>(); + private boolean perContextSummarization = false; public EventsConfiguration build() { return new EventsConfiguration( @@ -347,7 +348,8 @@ public EventsConfiguration build() { flushIntervalMillis, initiallyInBackground, initiallyOffline, - privateAttributes + privateAttributes, + perContextSummarization ); } @@ -410,6 +412,11 @@ public EventsConfigurationBuilder privateAttributes(Set privateAtt this.privateAttributes = privateAttributes; return this; } + + public EventsConfigurationBuilder perContextSummarization(boolean perContextSummarization) { + this.perContextSummarization = perContextSummarization; + return this; + } } public static EventContextDeduplicator contextDeduplicatorThatAlwaysSaysKeysAreNew() { diff --git a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/MultiContextEventSummarizerTest.java b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/MultiContextEventSummarizerTest.java new file mode 100644 index 00000000..111d5d72 --- /dev/null +++ b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/MultiContextEventSummarizerTest.java @@ -0,0 +1,385 @@ +package com.launchdarkly.sdk.internal.events; + +import com.launchdarkly.sdk.ContextBuilder; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.internal.events.EventSummarizer.EventSummary; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +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; + +public class MultiContextEventSummarizerTest { + private static final LDContext context1 = LDContext.create("user-key-1"); + private static final LDContext context2 = LDContext.create("user-key-2"); + private static final LDContext context3 = LDContext.builder("org-key-1").kind("organization").build(); + + @Test + public void summarizeEventCreatesNewSummarizerForNewContext() { + MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + + mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); + + List summaries = mcs.getSummariesAndReset(); + assertEquals(1, summaries.size()); + assertEquals(context1, summaries.get(0).context); + assertFalse(summaries.get(0).isEmpty()); + } + + @Test + public void summarizeEventRoutesToCorrectContextSummarizer() { + MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + + // Add events for two different contexts + mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); + mcs.summarizeEvent(1001, "flag2", 22, 2, LDValue.of("value2"), LDValue.of("default2"), context2); + + List summaries = mcs.getSummariesAndReset(); + assertEquals(2, summaries.size()); + + // Find summaries by context (order not guaranteed) + EventSummary summary1 = findSummaryByContext(summaries, context1); + EventSummary summary2 = findSummaryByContext(summaries, context2); + + assertNotNull(summary1); + assertNotNull(summary2); + assertEquals(1, summary1.counters.size()); + assertEquals(1, summary2.counters.size()); + assertTrue(summary1.counters.containsKey("flag1")); + assertTrue(summary2.counters.containsKey("flag2")); + } + + @Test + public void summarizeEventAccumulatesForSameContext() { + MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + + // Add multiple events for the same context + mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); + mcs.summarizeEvent(1001, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); + mcs.summarizeEvent(1002, "flag2", 22, 2, LDValue.of("value2"), LDValue.of("default2"), context1); + + List summaries = mcs.getSummariesAndReset(); + assertEquals(1, summaries.size()); + + EventSummary summary = summaries.get(0); + assertEquals(context1, summary.context); + assertEquals(2, summary.counters.size()); + + // Check that flag1 was counted twice + assertEquals(2, summary.counters.get("flag1").versionsAndVariations.get(11).get(1).count); + } + + @Test + public void multipleDifferentContextsProduceMultipleSummaries() { + MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + + // Add events for three different contexts + mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); + mcs.summarizeEvent(1001, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context2); + mcs.summarizeEvent(1002, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context3); + + List summaries = mcs.getSummariesAndReset(); + assertEquals(3, summaries.size()); + + // Verify each summary has the correct context + EventSummary summary1 = findSummaryByContext(summaries, context1); + EventSummary summary2 = findSummaryByContext(summaries, context2); + EventSummary summary3 = findSummaryByContext(summaries, context3); + + assertNotNull(summary1); + assertNotNull(summary2); + assertNotNull(summary3); + } + + @Test + public void getSummariesAndResetClearsState() { + MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + + mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); + + List summaries1 = mcs.getSummariesAndReset(); + assertEquals(1, summaries1.size()); + + // After reset, should be empty + assertTrue(mcs.isEmpty()); + + List summaries2 = mcs.getSummariesAndReset(); + assertEquals(0, summaries2.size()); + } + + @Test + public void isEmptyReturnsTrueWhenNoEvents() { + MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + assertTrue(mcs.isEmpty()); + } + + @Test + public void isEmptyReturnsFalseWhenEventsExist() { + MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + + mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); + + assertFalse(mcs.isEmpty()); + } + + @Test + public void clearRemovesAllSummaries() { + MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + + mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); + mcs.summarizeEvent(1001, "flag2", 22, 2, LDValue.of("value2"), LDValue.of("default2"), context2); + + assertFalse(mcs.isEmpty()); + + mcs.clear(); + + assertTrue(mcs.isEmpty()); + List summaries = mcs.getSummariesAndReset(); + assertEquals(0, summaries.size()); + } + + @Test + public void getSummariesAndResetFiltersEmptySummaries() { + MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + + // Add events + mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); + + // Get summaries (this resets the state, creating new empty summarizers) + List summaries = mcs.getSummariesAndReset(); + assertEquals(1, summaries.size()); + + // Now the internal state is reset, so getting summaries again should return empty list + List emptySummaries = mcs.getSummariesAndReset(); + assertEquals(0, emptySummaries.size()); + } + + @Test + public void contextWithSameKeyButDifferentKindCreatesMultipleSummaries() { + MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + + LDContext userContext = LDContext.builder("same-key").kind("user").build(); + LDContext orgContext = LDContext.builder("same-key").kind("organization").build(); + + mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), userContext); + mcs.summarizeEvent(1001, "flag2", 22, 2, LDValue.of("value2"), LDValue.of("default2"), orgContext); + + List summaries = mcs.getSummariesAndReset(); + assertEquals(2, summaries.size()); + } + + @Test + public void multiKindContextCreatesOneSummary() { + MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + + LDContext multiContext = LDContext.createMulti( + LDContext.create("user-key"), + LDContext.builder("org-key").kind("organization").build() + ); + + mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), multiContext); + mcs.summarizeEvent(1001, "flag2", 22, 2, LDValue.of("value2"), LDValue.of("default2"), multiContext); + + List summaries = mcs.getSummariesAndReset(); + assertEquals(1, summaries.size()); + assertEquals(multiContext, summaries.get(0).context); + assertEquals(2, summaries.get(0).counters.size()); + } + + @Test + public void timestampsAreTrackedPerContext() { + MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + + mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); + mcs.summarizeEvent(2000, "flag2", 22, 2, LDValue.of("value2"), LDValue.of("default2"), context2); + mcs.summarizeEvent(1500, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); + + List summaries = mcs.getSummariesAndReset(); + assertEquals(2, summaries.size()); + + EventSummary summary1 = findSummaryByContext(summaries, context1); + EventSummary summary2 = findSummaryByContext(summaries, context2); + + assertEquals(1000, summary1.startDate); + assertEquals(1500, summary1.endDate); + assertEquals(2000, summary2.startDate); + assertEquals(2000, summary2.endDate); + } + + @Test + public void contextKindsAreTrackedPerContext() { + MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + + LDContext multiContext1 = LDContext.createMulti( + LDContext.create("user-key-1"), + LDContext.builder("org-key-1").kind("organization").build() + ); + + LDContext multiContext2 = LDContext.createMulti( + LDContext.create("user-key-2"), + LDContext.builder("device-key-1").kind("device").build() + ); + + mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), multiContext1); + mcs.summarizeEvent(1001, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), multiContext2); + + List summaries = mcs.getSummariesAndReset(); + assertEquals(2, summaries.size()); + + EventSummary summary1 = findSummaryByContext(summaries, multiContext1); + EventSummary summary2 = findSummaryByContext(summaries, multiContext2); + + // Summary1 should have user and organization kinds for flag1 + assertTrue(summary1.counters.get("flag1").contextKinds.contains("user")); + assertTrue(summary1.counters.get("flag1").contextKinds.contains("organization")); + + // Summary2 should have user and device kinds for flag1 + assertTrue(summary2.counters.get("flag1").contextKinds.contains("user")); + assertTrue(summary2.counters.get("flag1").contextKinds.contains("device")); + } + + @Test + public void manyContextsHandledCorrectly() { + MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + int contextCount = 100; + + // Add events for many different contexts + for (int i = 0; i < contextCount; i++) { + LDContext context = LDContext.create("user-key-" + i); + mcs.summarizeEvent(1000 + i, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context); + } + + List summaries = mcs.getSummariesAndReset(); + assertEquals(contextCount, summaries.size()); + + // Verify each summary is non-empty and has the correct context + for (EventSummary summary : summaries) { + assertNotNull(summary.context); + assertFalse(summary.isEmpty()); + assertEquals(1, summary.counters.size()); + } + } + + @Test + public void restoreToRestoresPreviousState() { + MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + + // Add events for two contexts + mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); + mcs.summarizeEvent(1001, "flag2", 22, 2, LDValue.of("value2"), LDValue.of("default2"), context2); + + // Get summaries (this clears the state) + List summaries = mcs.getSummariesAndReset(); + assertEquals(2, summaries.size()); + + // Verify state is now empty + assertTrue(mcs.isEmpty()); + + // Restore the previous state + mcs.restoreTo(summaries); + + // Verify state was restored + assertFalse(mcs.isEmpty()); + + // Get summaries again and verify they match + List restoredSummaries = mcs.getSummariesAndReset(); + assertEquals(2, restoredSummaries.size()); + + EventSummary restored1 = findSummaryByContext(restoredSummaries, context1); + EventSummary restored2 = findSummaryByContext(restoredSummaries, context2); + + assertNotNull(restored1); + assertNotNull(restored2); + assertTrue(restored1.counters.containsKey("flag1")); + assertTrue(restored2.counters.containsKey("flag2")); + } + + @Test + public void restoreToHandlesEmptySummaries() { + MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + + // Add an event + mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); + assertFalse(mcs.isEmpty()); + + // Create an empty summary list + List emptySummaries = new ArrayList<>(); + + // Restore to empty list + mcs.restoreTo(emptySummaries); + + // Should now be empty + assertTrue(mcs.isEmpty()); + } + + @Test + public void restoreToPreservesCountsAndTimestamps() { + MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + + // Add multiple events for same flag/context + mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); + mcs.summarizeEvent(1500, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); + mcs.summarizeEvent(2000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); + + // Get summaries + List summaries = mcs.getSummariesAndReset(); + EventSummary original = summaries.get(0); + + // Verify original state + assertEquals(1000, original.startDate); + assertEquals(2000, original.endDate); + assertEquals(3, original.counters.get("flag1").versionsAndVariations.get(11).get(1).count); + + // Restore + mcs.restoreTo(summaries); + + // Get summaries again and verify timestamps and counts preserved + List restored = mcs.getSummariesAndReset(); + EventSummary restoredSummary = restored.get(0); + + assertEquals(1000, restoredSummary.startDate); + assertEquals(2000, restoredSummary.endDate); + assertEquals(3, restoredSummary.counters.get("flag1").versionsAndVariations.get(11).get(1).count); + } + + @Test + public void restoreToAllowsContinuedAccumulation() { + MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + + // Add events + mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); + + // Get and restore + List summaries = mcs.getSummariesAndReset(); + mcs.restoreTo(summaries); + + // Add more events to the same context + mcs.summarizeEvent(1500, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); + + // Verify counts accumulated + List finalSummaries = mcs.getSummariesAndReset(); + EventSummary finalSummary = finalSummaries.get(0); + + // Should have count of 2 (1 original + 1 new) + assertEquals(2, finalSummary.counters.get("flag1").versionsAndVariations.get(11).get(1).count); + // Timestamps should span from first to last + assertEquals(1000, finalSummary.startDate); + assertEquals(1500, finalSummary.endDate); + } + + private EventSummary findSummaryByContext(List summaries, LDContext context) { + for (EventSummary summary : summaries) { + if (context.equals(summary.context)) { + return summary; + } + } + return null; + } +} From 3a0f579ed213b0ddf93305d986b94034601b7c3f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:41:25 -0800 Subject: [PATCH 3/5] Refactoring. --- .../events/AggregatedEventSummarizer.java | 65 ++++++++++++++++ .../events/DefaultEventProcessor.java | 77 +++++-------------- .../events/EventSummarizerInterface.java | 64 +++++++++++++++ ...er.java => PerContextEventSummarizer.java} | 24 ++++-- ...ava => PerContextEventSummarizerTest.java} | 38 ++++----- 5 files changed, 182 insertions(+), 86 deletions(-) create mode 100644 lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/AggregatedEventSummarizer.java create mode 100644 lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventSummarizerInterface.java rename lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/{MultiContextEventSummarizer.java => PerContextEventSummarizer.java} (83%) rename lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/{MultiContextEventSummarizerTest.java => PerContextEventSummarizerTest.java} (90%) diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/AggregatedEventSummarizer.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/AggregatedEventSummarizer.java new file mode 100644 index 00000000..59bb6bad --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/AggregatedEventSummarizer.java @@ -0,0 +1,65 @@ +package com.launchdarkly.sdk.internal.events; + +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.internal.events.EventSummarizer.EventSummary; + +import java.util.Collections; +import java.util.List; + +/** + * Aggregates events from all contexts into a single summary event. + * This provides a unified interface for both aggregated and per-context summarization, + * eliminating conditional branching in the event processor. + *

+ * This implementation combines all flag evaluations across all contexts into one + * summary event (without context information), which is the traditional behavior. + *

+ * Note that the methods of this class are deliberately not thread-safe, because they should + * always be called from EventProcessor's single message-processing thread. + */ +final class AggregatedEventSummarizer implements EventSummarizerInterface { + private final EventSummarizer summarizer; + + AggregatedEventSummarizer() { + this.summarizer = new EventSummarizer(); + } + + @Override + public void summarizeEvent( + long timestamp, + String flagKey, + int flagVersion, + int variation, + LDValue value, + LDValue defaultValue, + LDContext context + ) { + summarizer.summarizeEvent(timestamp, flagKey, flagVersion, variation, value, defaultValue, context); + } + + @Override + public List getSummariesAndReset() { + EventSummary summary = summarizer.getSummaryAndReset(); + // Always return a list with exactly one summary for consistency with interface + return Collections.singletonList(summary); + } + + @Override + public void restoreTo(List previousSummaries) { + // In aggregated mode, we only restore the first summary (should only be one anyway) + if (!previousSummaries.isEmpty()) { + summarizer.restoreTo(previousSummaries.get(0)); + } + } + + @Override + public boolean isEmpty() { + return summarizer.isEmpty(); + } + + @Override + public void clear() { + summarizer.clear(); + } +} diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java index d990f6e8..51d18b04 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java @@ -624,16 +624,8 @@ private void triggerFlush(EventBuffer outbox, BlockingQueue payloa } else { logger.debug("Skipped flushing because all workers are busy"); // All the workers are busy so we can't flush now; keep the events in our state - if (outbox.perContextSummarization) { - // Per-context mode: restore all summaries - if (outbox.multiContextSummarizer != null && !payload.summaries.isEmpty()) { - outbox.multiContextSummarizer.restoreTo(payload.summaries); - } - } else { - // Single summary mode: restore single summary - if (outbox.summarizer != null && !payload.summaries.isEmpty()) { - outbox.summarizer.restoreTo(payload.summaries.get(0)); - } + if (!payload.summaries.isEmpty()) { + outbox.summarizer.restoreTo(payload.summaries); } synchronized(busyFlushWorkersCount) { busyFlushWorkersCount.decrementAndGet(); @@ -677,9 +669,7 @@ public void run() { private static final class EventBuffer { final List events = new ArrayList<>(); - final EventSummarizer summarizer; // used when perContextSummarization is false - final MultiContextEventSummarizer multiContextSummarizer; // used when perContextSummarization is true - private final boolean perContextSummarization; + final EventSummarizerInterface summarizer; private final int capacity; private final LDLogger logger; private boolean capacityExceeded = false; @@ -687,15 +677,10 @@ private static final class EventBuffer { EventBuffer(int capacity, boolean perContextSummarization, LDLogger logger) { this.capacity = capacity; - this.perContextSummarization = perContextSummarization; this.logger = logger; - if (perContextSummarization) { - this.summarizer = null; - this.multiContextSummarizer = new MultiContextEventSummarizer(); - } else { - this.summarizer = new EventSummarizer(); - this.multiContextSummarizer = null; - } + this.summarizer = perContextSummarization + ? new PerContextEventSummarizer() + : new AggregatedEventSummarizer(); } void add(Event e) { @@ -712,35 +697,19 @@ void add(Event e) { } void addToSummary(Event.FeatureRequest e) { - if (perContextSummarization) { - multiContextSummarizer.summarizeEvent( - e.getCreationDate(), - e.getKey(), - e.getVersion(), - e.getVariation(), - e.getValue(), - e.getDefaultVal(), - e.getContext() - ); - } else { - summarizer.summarizeEvent( - e.getCreationDate(), - e.getKey(), - e.getVersion(), - e.getVariation(), - e.getValue(), - e.getDefaultVal(), - e.getContext() - ); - } + summarizer.summarizeEvent( + e.getCreationDate(), + e.getKey(), + e.getVersion(), + e.getVariation(), + e.getValue(), + e.getDefaultVal(), + e.getContext() + ); } boolean isEmpty() { - if (perContextSummarization) { - return events.isEmpty() && multiContextSummarizer.isEmpty(); - } else { - return events.isEmpty() && summarizer.isEmpty(); - } + return events.isEmpty() && summarizer.isEmpty(); } long getAndClearDroppedCount() { @@ -751,23 +720,13 @@ long getAndClearDroppedCount() { FlushPayload getPayload() { Event[] eventsOut = events.toArray(new Event[events.size()]); - List summaries; - if (perContextSummarization) { - summaries = multiContextSummarizer.getSummariesAndReset(); - } else { - EventSummarizer.EventSummary summary = summarizer.getSummaryAndReset(); - summaries = java.util.Collections.singletonList(summary); - } + List summaries = summarizer.getSummariesAndReset(); return new FlushPayload(eventsOut, summaries); } void clear() { events.clear(); - if (perContextSummarization) { - multiContextSummarizer.clear(); - } else { - summarizer.clear(); - } + summarizer.clear(); } } diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventSummarizerInterface.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventSummarizerInterface.java new file mode 100644 index 00000000..4923d338 --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventSummarizerInterface.java @@ -0,0 +1,64 @@ +package com.launchdarkly.sdk.internal.events; + +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.internal.events.EventSummarizer.EventSummary; + +import java.util.List; + +/** + * Interface for event summarization strategies. Implementations can provide either + * single-summary (aggregated) or per-context summary behavior. + *

+ * Note that implementations are deliberately not thread-safe, as they should always + * be called from EventProcessor's single message-processing thread. + */ +interface EventSummarizerInterface { + /** + * Adds information about an evaluation to the summary. + * + * @param timestamp the millisecond timestamp + * @param flagKey the flag key + * @param flagVersion the flag version, or -1 if the flag is unknown + * @param variation the result variation, or -1 if none + * @param value the result value + * @param defaultValue the application default value + * @param context the evaluation context + */ + void summarizeEvent( + long timestamp, + String flagKey, + int flagVersion, + int variation, + LDValue value, + LDValue defaultValue, + LDContext context + ); + + /** + * Gets all current summarized event data and resets the state to empty. + * + * @return list of summary states (may contain one or many summaries depending on implementation) + */ + List getSummariesAndReset(); + + /** + * Restores the summarizer state from a previous snapshot. This is used when a flush + * operation fails and we need to keep the summary data for the next attempt. + * + * @param previousSummaries the list of summaries to restore + */ + void restoreTo(List previousSummaries); + + /** + * Returns true if there is no summary data. + * + * @return true if the state is empty + */ + boolean isEmpty(); + + /** + * Clears all summary data. + */ + void clear(); +} diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/MultiContextEventSummarizer.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/PerContextEventSummarizer.java similarity index 83% rename from lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/MultiContextEventSummarizer.java rename to lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/PerContextEventSummarizer.java index f2cb3371..0035c5d2 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/MultiContextEventSummarizer.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/PerContextEventSummarizer.java @@ -9,16 +9,19 @@ import java.util.Map; /** - * Manages per-context event summarization. Maintains separate EventSummarizer instances + * Generates separate summary events per context. Maintains separate EventSummarizer instances * for each unique context, allowing generation of multiple summary events per flush interval. *

+ * This implementation creates one summary event per context, each including the context + * information, enabling "who got what when" analytics. + *

* Note that the methods of this class are deliberately not thread-safe, because they should * always be called from EventProcessor's single message-processing thread. */ -final class MultiContextEventSummarizer { +final class PerContextEventSummarizer implements EventSummarizerInterface { private final Map summarizersByContext; - MultiContextEventSummarizer() { + PerContextEventSummarizer() { this.summarizersByContext = new HashMap<>(); } @@ -33,7 +36,8 @@ final class MultiContextEventSummarizer { * @param defaultValue the application default value * @param context the evaluation context */ - void summarizeEvent( + @Override + public void summarizeEvent( long timestamp, String flagKey, int flagVersion, @@ -58,7 +62,8 @@ void summarizeEvent( * * @return list of summary states, one for each context that had events */ - List getSummariesAndReset() { + @Override + public List getSummariesAndReset() { List summaries = new ArrayList<>(); for (EventSummarizer summarizer : summarizersByContext.values()) { EventSummarizer.EventSummary summary = summarizer.getSummaryAndReset(); @@ -76,7 +81,8 @@ List getSummariesAndReset() { * * @param previousSummaries the list of summaries to restore */ - void restoreTo(List previousSummaries) { + @Override + public void restoreTo(List previousSummaries) { summarizersByContext.clear(); for (EventSummarizer.EventSummary summary : previousSummaries) { if (summary.context != null && !summary.isEmpty()) { @@ -92,7 +98,8 @@ void restoreTo(List previousSummaries) { * * @return true if all contexts have empty state */ - boolean isEmpty() { + @Override + public boolean isEmpty() { for (EventSummarizer summarizer : summarizersByContext.values()) { if (!summarizer.isEmpty()) { return false; @@ -104,7 +111,8 @@ boolean isEmpty() { /** * Clears all summarizers and context tracking. */ - void clear() { + @Override + public void clear() { summarizersByContext.clear(); } } diff --git a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/MultiContextEventSummarizerTest.java b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/PerContextEventSummarizerTest.java similarity index 90% rename from lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/MultiContextEventSummarizerTest.java rename to lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/PerContextEventSummarizerTest.java index 111d5d72..ff0dac37 100644 --- a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/MultiContextEventSummarizerTest.java +++ b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/PerContextEventSummarizerTest.java @@ -16,14 +16,14 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; -public class MultiContextEventSummarizerTest { +public class PerContextEventSummarizerTest { private static final LDContext context1 = LDContext.create("user-key-1"); private static final LDContext context2 = LDContext.create("user-key-2"); private static final LDContext context3 = LDContext.builder("org-key-1").kind("organization").build(); @Test public void summarizeEventCreatesNewSummarizerForNewContext() { - MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + PerContextEventSummarizer mcs = new PerContextEventSummarizer(); mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); @@ -35,7 +35,7 @@ public void summarizeEventCreatesNewSummarizerForNewContext() { @Test public void summarizeEventRoutesToCorrectContextSummarizer() { - MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + PerContextEventSummarizer mcs = new PerContextEventSummarizer(); // Add events for two different contexts mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); @@ -58,7 +58,7 @@ public void summarizeEventRoutesToCorrectContextSummarizer() { @Test public void summarizeEventAccumulatesForSameContext() { - MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + PerContextEventSummarizer mcs = new PerContextEventSummarizer(); // Add multiple events for the same context mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); @@ -78,7 +78,7 @@ public void summarizeEventAccumulatesForSameContext() { @Test public void multipleDifferentContextsProduceMultipleSummaries() { - MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + PerContextEventSummarizer mcs = new PerContextEventSummarizer(); // Add events for three different contexts mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); @@ -100,7 +100,7 @@ public void multipleDifferentContextsProduceMultipleSummaries() { @Test public void getSummariesAndResetClearsState() { - MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + PerContextEventSummarizer mcs = new PerContextEventSummarizer(); mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); @@ -116,13 +116,13 @@ public void getSummariesAndResetClearsState() { @Test public void isEmptyReturnsTrueWhenNoEvents() { - MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + PerContextEventSummarizer mcs = new PerContextEventSummarizer(); assertTrue(mcs.isEmpty()); } @Test public void isEmptyReturnsFalseWhenEventsExist() { - MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + PerContextEventSummarizer mcs = new PerContextEventSummarizer(); mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); @@ -131,7 +131,7 @@ public void isEmptyReturnsFalseWhenEventsExist() { @Test public void clearRemovesAllSummaries() { - MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + PerContextEventSummarizer mcs = new PerContextEventSummarizer(); mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); mcs.summarizeEvent(1001, "flag2", 22, 2, LDValue.of("value2"), LDValue.of("default2"), context2); @@ -147,7 +147,7 @@ public void clearRemovesAllSummaries() { @Test public void getSummariesAndResetFiltersEmptySummaries() { - MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + PerContextEventSummarizer mcs = new PerContextEventSummarizer(); // Add events mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); @@ -163,7 +163,7 @@ public void getSummariesAndResetFiltersEmptySummaries() { @Test public void contextWithSameKeyButDifferentKindCreatesMultipleSummaries() { - MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + PerContextEventSummarizer mcs = new PerContextEventSummarizer(); LDContext userContext = LDContext.builder("same-key").kind("user").build(); LDContext orgContext = LDContext.builder("same-key").kind("organization").build(); @@ -177,7 +177,7 @@ public void contextWithSameKeyButDifferentKindCreatesMultipleSummaries() { @Test public void multiKindContextCreatesOneSummary() { - MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + PerContextEventSummarizer mcs = new PerContextEventSummarizer(); LDContext multiContext = LDContext.createMulti( LDContext.create("user-key"), @@ -195,7 +195,7 @@ public void multiKindContextCreatesOneSummary() { @Test public void timestampsAreTrackedPerContext() { - MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + PerContextEventSummarizer mcs = new PerContextEventSummarizer(); mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); mcs.summarizeEvent(2000, "flag2", 22, 2, LDValue.of("value2"), LDValue.of("default2"), context2); @@ -215,7 +215,7 @@ public void timestampsAreTrackedPerContext() { @Test public void contextKindsAreTrackedPerContext() { - MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + PerContextEventSummarizer mcs = new PerContextEventSummarizer(); LDContext multiContext1 = LDContext.createMulti( LDContext.create("user-key-1"), @@ -247,7 +247,7 @@ public void contextKindsAreTrackedPerContext() { @Test public void manyContextsHandledCorrectly() { - MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + PerContextEventSummarizer mcs = new PerContextEventSummarizer(); int contextCount = 100; // Add events for many different contexts @@ -269,7 +269,7 @@ public void manyContextsHandledCorrectly() { @Test public void restoreToRestoresPreviousState() { - MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + PerContextEventSummarizer mcs = new PerContextEventSummarizer(); // Add events for two contexts mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); @@ -303,7 +303,7 @@ public void restoreToRestoresPreviousState() { @Test public void restoreToHandlesEmptySummaries() { - MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + PerContextEventSummarizer mcs = new PerContextEventSummarizer(); // Add an event mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); @@ -321,7 +321,7 @@ public void restoreToHandlesEmptySummaries() { @Test public void restoreToPreservesCountsAndTimestamps() { - MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + PerContextEventSummarizer mcs = new PerContextEventSummarizer(); // Add multiple events for same flag/context mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); @@ -351,7 +351,7 @@ public void restoreToPreservesCountsAndTimestamps() { @Test public void restoreToAllowsContinuedAccumulation() { - MultiContextEventSummarizer mcs = new MultiContextEventSummarizer(); + PerContextEventSummarizer mcs = new PerContextEventSummarizer(); // Add events mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1); From 6c244facbf90785547913208b00b40f7265bf3a7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:38:56 -0800 Subject: [PATCH 4/5] Minor code cleanup. --- .../events/AggregatedEventSummarizer.java | 18 ++++----- .../events/EventSummarizerInterface.java | 28 ++++++------- .../events/PerContextEventSummarizer.java | 39 +++++++++---------- 3 files changed, 40 insertions(+), 45 deletions(-) diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/AggregatedEventSummarizer.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/AggregatedEventSummarizer.java index 59bb6bad..ec81c25e 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/AggregatedEventSummarizer.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/AggregatedEventSummarizer.java @@ -9,11 +9,9 @@ /** * Aggregates events from all contexts into a single summary event. - * This provides a unified interface for both aggregated and per-context summarization, - * eliminating conditional branching in the event processor. *

* This implementation combines all flag evaluations across all contexts into one - * summary event (without context information), which is the traditional behavior. + * summary event (without context information), which is the behavior for server-side SDKs. *

* Note that the methods of this class are deliberately not thread-safe, because they should * always be called from EventProcessor's single message-processing thread. @@ -27,13 +25,13 @@ final class AggregatedEventSummarizer implements EventSummarizerInterface { @Override public void summarizeEvent( - long timestamp, - String flagKey, - int flagVersion, - int variation, - LDValue value, - LDValue defaultValue, - LDContext context + long timestamp, + String flagKey, + int flagVersion, + int variation, + LDValue value, + LDValue defaultValue, + LDContext context ) { summarizer.summarizeEvent(timestamp, flagKey, flagVersion, variation, value, defaultValue, context); } diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventSummarizerInterface.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventSummarizerInterface.java index 4923d338..6dcffba1 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventSummarizerInterface.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventSummarizerInterface.java @@ -17,22 +17,22 @@ interface EventSummarizerInterface { /** * Adds information about an evaluation to the summary. * - * @param timestamp the millisecond timestamp - * @param flagKey the flag key - * @param flagVersion the flag version, or -1 if the flag is unknown - * @param variation the result variation, or -1 if none - * @param value the result value + * @param timestamp the millisecond timestamp + * @param flagKey the flag key + * @param flagVersion the flag version, or -1 if the flag is unknown + * @param variation the result variation, or -1 if none + * @param value the result value * @param defaultValue the application default value - * @param context the evaluation context + * @param context the evaluation context */ void summarizeEvent( - long timestamp, - String flagKey, - int flagVersion, - int variation, - LDValue value, - LDValue defaultValue, - LDContext context + long timestamp, + String flagKey, + int flagVersion, + int variation, + LDValue value, + LDValue defaultValue, + LDContext context ); /** @@ -44,7 +44,7 @@ void summarizeEvent( /** * Restores the summarizer state from a previous snapshot. This is used when a flush - * operation fails and we need to keep the summary data for the next attempt. + * operation fails, and we need to keep the summary data for the next attempt. * * @param previousSummaries the list of summaries to restore */ diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/PerContextEventSummarizer.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/PerContextEventSummarizer.java index 0035c5d2..b96d9f90 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/PerContextEventSummarizer.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/PerContextEventSummarizer.java @@ -11,6 +11,7 @@ /** * Generates separate summary events per context. Maintains separate EventSummarizer instances * for each unique context, allowing generation of multiple summary events per flush interval. + * This implementation is intended for use by client-side SDKs. *

* This implementation creates one summary event per context, each including the context * information, enabling "who got what when" analytics. @@ -28,30 +29,26 @@ final class PerContextEventSummarizer implements EventSummarizerInterface { /** * Adds information about an evaluation to the appropriate context's summarizer. * - * @param timestamp the millisecond timestamp - * @param flagKey the flag key - * @param flagVersion the flag version, or -1 if the flag is unknown - * @param variation the result variation, or -1 if none - * @param value the result value + * @param timestamp the millisecond timestamp + * @param flagKey the flag key + * @param flagVersion the flag version, or -1 if the flag is unknown + * @param variation the result variation, or -1 if none + * @param value the result value * @param defaultValue the application default value - * @param context the evaluation context + * @param context the evaluation context */ @Override public void summarizeEvent( - long timestamp, - String flagKey, - int flagVersion, - int variation, - LDValue value, - LDValue defaultValue, - LDContext context - ) { + long timestamp, + String flagKey, + int flagVersion, + int variation, + LDValue value, + LDValue defaultValue, + LDContext context + ) { // Get or create summarizer for this context - EventSummarizer summarizer = summarizersByContext.get(context); - if (summarizer == null) { - summarizer = new EventSummarizer(context); - summarizersByContext.put(context, summarizer); - } + EventSummarizer summarizer = summarizersByContext.computeIfAbsent(context, EventSummarizer::new); // Delegate to the per-context summarizer summarizer.summarizeEvent(timestamp, flagKey, flagVersion, variation, value, defaultValue, context); @@ -77,7 +74,7 @@ public List getSummariesAndReset() { /** * Restores the summarizer state from a previous snapshot. This is used when a flush - * operation fails and we need to keep the summary data for the next attempt. + * operation fails, and we need to keep the summary data for the next attempt. * * @param previousSummaries the list of summaries to restore */ @@ -96,7 +93,7 @@ public void restoreTo(List previousSummaries) { /** * Returns true if there is no summary data for any context. * - * @return true if all contexts have empty state + * @return true if all contexts have are empty */ @Override public boolean isEmpty() { From 0a576bdb21925d468b251aacf58fa372a1ec297b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:40:28 -0800 Subject: [PATCH 5/5] Remove check that isn't required. --- .../sdk/internal/events/DefaultEventProcessor.java | 4 +--- .../sdk/internal/events/PerContextEventSummarizerTest.java | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java index 51d18b04..f258243b 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java @@ -624,9 +624,7 @@ private void triggerFlush(EventBuffer outbox, BlockingQueue payloa } else { logger.debug("Skipped flushing because all workers are busy"); // All the workers are busy so we can't flush now; keep the events in our state - if (!payload.summaries.isEmpty()) { - outbox.summarizer.restoreTo(payload.summaries); - } + outbox.summarizer.restoreTo(payload.summaries); synchronized(busyFlushWorkersCount) { busyFlushWorkersCount.decrementAndGet(); busyFlushWorkersCount.notify(); diff --git a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/PerContextEventSummarizerTest.java b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/PerContextEventSummarizerTest.java index ff0dac37..7a55ea57 100644 --- a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/PerContextEventSummarizerTest.java +++ b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/PerContextEventSummarizerTest.java @@ -23,7 +23,7 @@ public class PerContextEventSummarizerTest { @Test public void summarizeEventCreatesNewSummarizerForNewContext() { - PerContextEventSummarizer mcs = new PerContextEventSummarizer(); + PerContextEventSummarizer mcs = new PerContextEventSummarizer(); mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1);