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; + } +}