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..ec81c25e
--- /dev/null
+++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/AggregatedEventSummarizer.java
@@ -0,0 +1,63 @@
+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 implementation combines all flag evaluations across all contexts into one
+ * 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.
+ */
+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 1ad7a02f..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
@@ -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,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
- outbox.summarizer.restoreTo(payload.summary);
+ outbox.summarizer.restoreTo(payload.summaries);
synchronized(busyFlushWorkersCount) {
busyFlushWorkersCount.decrementAndGet();
busyFlushWorkersCount.notify();
@@ -661,15 +667,18 @@ public void run() {
private static final class EventBuffer {
final List events = new ArrayList<>();
- final EventSummarizer summarizer = new EventSummarizer();
+ final EventSummarizerInterface summarizer;
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.logger = logger;
+ this.summarizer = perContextSummarization
+ ? new PerContextEventSummarizer()
+ : new AggregatedEventSummarizer();
}
void add(Event e) {
@@ -694,7 +703,7 @@ void addToSummary(Event.FeatureRequest e) {
e.getValue(),
e.getDefaultVal(),
e.getContext()
- );
+ );
}
boolean isEmpty() {
@@ -709,8 +718,8 @@ long getAndClearDroppedCount() {
FlushPayload getPayload() {
Event[] eventsOut = events.toArray(new Event[events.size()]);
- EventSummarizer.EventSummary summary = summarizer.getSummaryAndReset();
- return new FlushPayload(eventsOut, summary);
+ List summaries = summarizer.getSummariesAndReset();
+ return new FlushPayload(eventsOut, summaries);
}
void clear() {
@@ -721,11 +730,11 @@ void 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 +783,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/EventSummarizerInterface.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventSummarizerInterface.java
new file mode 100644
index 00000000..6dcffba1
--- /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/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/PerContextEventSummarizer.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/PerContextEventSummarizer.java
new file mode 100644
index 00000000..b96d9f90
--- /dev/null
+++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/PerContextEventSummarizer.java
@@ -0,0 +1,115 @@
+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;
+
+/**
+ * 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.
+ *
+ * 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 PerContextEventSummarizer implements EventSummarizerInterface {
+ private final Map summarizersByContext;
+
+ PerContextEventSummarizer() {
+ 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
+ */
+ @Override
+ public 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.computeIfAbsent(context, EventSummarizer::new);
+
+ // 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
+ */
+ @Override
+ public 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;
+ }
+
+ /**
+ * 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
+ */
+ @Override
+ public 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.
+ *
+ * @return true if all contexts have are empty
+ */
+ @Override
+ public boolean isEmpty() {
+ for (EventSummarizer summarizer : summarizersByContext.values()) {
+ if (!summarizer.isEmpty()) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Clears all summarizers and context tracking.
+ */
+ @Override
+ public void clear() {
+ summarizersByContext.clear();
+ }
+}
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/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);
}
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
new file mode 100644
index 00000000..7a55ea57
--- /dev/null
+++ b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/PerContextEventSummarizerTest.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 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() {
+ PerContextEventSummarizer mcs = new PerContextEventSummarizer();
+
+ 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() {
+ PerContextEventSummarizer mcs = new PerContextEventSummarizer();
+
+ // 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() {
+ PerContextEventSummarizer mcs = new PerContextEventSummarizer();
+
+ // 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() {
+ PerContextEventSummarizer mcs = new PerContextEventSummarizer();
+
+ // 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() {
+ PerContextEventSummarizer mcs = new PerContextEventSummarizer();
+
+ 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() {
+ PerContextEventSummarizer mcs = new PerContextEventSummarizer();
+ assertTrue(mcs.isEmpty());
+ }
+
+ @Test
+ public void isEmptyReturnsFalseWhenEventsExist() {
+ PerContextEventSummarizer mcs = new PerContextEventSummarizer();
+
+ mcs.summarizeEvent(1000, "flag1", 11, 1, LDValue.of("value1"), LDValue.of("default1"), context1);
+
+ assertFalse(mcs.isEmpty());
+ }
+
+ @Test
+ public void clearRemovesAllSummaries() {
+ 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);
+
+ assertFalse(mcs.isEmpty());
+
+ mcs.clear();
+
+ assertTrue(mcs.isEmpty());
+ List summaries = mcs.getSummariesAndReset();
+ assertEquals(0, summaries.size());
+ }
+
+ @Test
+ public void getSummariesAndResetFiltersEmptySummaries() {
+ PerContextEventSummarizer mcs = new PerContextEventSummarizer();
+
+ // 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() {
+ PerContextEventSummarizer mcs = new PerContextEventSummarizer();
+
+ 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() {
+ PerContextEventSummarizer mcs = new PerContextEventSummarizer();
+
+ 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() {
+ 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);
+ 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() {
+ PerContextEventSummarizer mcs = new PerContextEventSummarizer();
+
+ 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() {
+ PerContextEventSummarizer mcs = new PerContextEventSummarizer();
+ 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() {
+ PerContextEventSummarizer mcs = new PerContextEventSummarizer();
+
+ // 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() {
+ PerContextEventSummarizer mcs = new PerContextEventSummarizer();
+
+ // 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() {
+ PerContextEventSummarizer mcs = new PerContextEventSummarizer();
+
+ // 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() {
+ PerContextEventSummarizer mcs = new PerContextEventSummarizer();
+
+ // 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;
+ }
+}