Skip to content

Commit 3913d6f

Browse files
authored
feat: Add optional support for per-context summary events. (#135)
<!-- CURSOR_SUMMARY --> > [!NOTE] > **Medium Risk** > Touches core analytics event buffering/flush and JSON output paths; enabling per-context summarization changes payload shape/volume and includes context data in summary events, so regressions could affect event delivery and privacy expectations. > > **Overview** > Adds an optional per-context summarization mode that can emit **multiple** `summary` events per flush (one per `LDContext`) instead of a single aggregated summary. > > Refactors the event pipeline to use a new `EventSummarizerInterface` with `AggregatedEventSummarizer` (backward-compatible default) and `PerContextEventSummarizer`, extends `EventsConfiguration` with a `perContextSummarization` flag, and updates `DefaultEventProcessor`/`EventOutputFormatter` to send/restore a list of summaries and to include serialized `context` in summary output when present. Tests are updated and a comprehensive `PerContextEventSummarizerTest` is added. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3a0f579. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 4bc44d3 commit 3913d6f

10 files changed

Lines changed: 734 additions & 27 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package com.launchdarkly.sdk.internal.events;
2+
3+
import com.launchdarkly.sdk.LDContext;
4+
import com.launchdarkly.sdk.LDValue;
5+
import com.launchdarkly.sdk.internal.events.EventSummarizer.EventSummary;
6+
7+
import java.util.Collections;
8+
import java.util.List;
9+
10+
/**
11+
* Aggregates events from all contexts into a single summary event.
12+
* <p>
13+
* This implementation combines all flag evaluations across all contexts into one
14+
* summary event (without context information), which is the behavior for server-side SDKs.
15+
* <p>
16+
* Note that the methods of this class are deliberately not thread-safe, because they should
17+
* always be called from EventProcessor's single message-processing thread.
18+
*/
19+
final class AggregatedEventSummarizer implements EventSummarizerInterface {
20+
private final EventSummarizer summarizer;
21+
22+
AggregatedEventSummarizer() {
23+
this.summarizer = new EventSummarizer();
24+
}
25+
26+
@Override
27+
public void summarizeEvent(
28+
long timestamp,
29+
String flagKey,
30+
int flagVersion,
31+
int variation,
32+
LDValue value,
33+
LDValue defaultValue,
34+
LDContext context
35+
) {
36+
summarizer.summarizeEvent(timestamp, flagKey, flagVersion, variation, value, defaultValue, context);
37+
}
38+
39+
@Override
40+
public List<EventSummary> getSummariesAndReset() {
41+
EventSummary summary = summarizer.getSummaryAndReset();
42+
// Always return a list with exactly one summary for consistency with interface
43+
return Collections.singletonList(summary);
44+
}
45+
46+
@Override
47+
public void restoreTo(List<EventSummary> previousSummaries) {
48+
// In aggregated mode, we only restore the first summary (should only be one anyway)
49+
if (!previousSummaries.isEmpty()) {
50+
summarizer.restoreTo(previousSummaries.get(0));
51+
}
52+
}
53+
54+
@Override
55+
public boolean isEmpty() {
56+
return summarizer.isEmpty();
57+
}
58+
59+
@Override
60+
public void clear() {
61+
summarizer.clear();
62+
}
63+
}

lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/DefaultEventProcessor.java

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,7 @@ public Thread newThread(Runnable r) {
363363
// all the workers are busy.
364364
final BlockingQueue<FlushPayload> payloadQueue = new ArrayBlockingQueue<>(1);
365365

366-
final EventBuffer outbox = new EventBuffer(eventsConfig.capacity, logger);
366+
final EventBuffer outbox = new EventBuffer(eventsConfig.capacity, eventsConfig.perContextSummarization, logger);
367367
this.contextDeduplicator = eventsConfig.contextDeduplicator;
368368

369369
Thread mainThread = threadFactory.newThread(new Thread() {
@@ -608,7 +608,13 @@ private void triggerFlush(EventBuffer outbox, BlockingQueue<FlushPayload> payloa
608608
}
609609
FlushPayload payload = outbox.getPayload();
610610
if (diagnosticStore != null) {
611-
int eventCount = payload.events.length + (payload.summary.isEmpty() ? 0 : 1);
611+
int summaryCount = 0;
612+
for (EventSummary summary : payload.summaries) {
613+
if (!summary.isEmpty()) {
614+
summaryCount++;
615+
}
616+
}
617+
int eventCount = payload.events.length + summaryCount;
612618
diagnosticStore.recordEventsInBatch(eventCount);
613619
}
614620
busyFlushWorkersCount.incrementAndGet();
@@ -618,7 +624,7 @@ private void triggerFlush(EventBuffer outbox, BlockingQueue<FlushPayload> payloa
618624
} else {
619625
logger.debug("Skipped flushing because all workers are busy");
620626
// All the workers are busy so we can't flush now; keep the events in our state
621-
outbox.summarizer.restoreTo(payload.summary);
627+
outbox.summarizer.restoreTo(payload.summaries);
622628
synchronized(busyFlushWorkersCount) {
623629
busyFlushWorkersCount.decrementAndGet();
624630
busyFlushWorkersCount.notify();
@@ -661,15 +667,18 @@ public void run() {
661667

662668
private static final class EventBuffer {
663669
final List<Event> events = new ArrayList<>();
664-
final EventSummarizer summarizer = new EventSummarizer();
670+
final EventSummarizerInterface summarizer;
665671
private final int capacity;
666672
private final LDLogger logger;
667673
private boolean capacityExceeded = false;
668674
private long droppedEventCount = 0;
669675

670-
EventBuffer(int capacity, LDLogger logger) {
676+
EventBuffer(int capacity, boolean perContextSummarization, LDLogger logger) {
671677
this.capacity = capacity;
672678
this.logger = logger;
679+
this.summarizer = perContextSummarization
680+
? new PerContextEventSummarizer()
681+
: new AggregatedEventSummarizer();
673682
}
674683

675684
void add(Event e) {
@@ -694,7 +703,7 @@ void addToSummary(Event.FeatureRequest e) {
694703
e.getValue(),
695704
e.getDefaultVal(),
696705
e.getContext()
697-
);
706+
);
698707
}
699708

700709
boolean isEmpty() {
@@ -709,8 +718,8 @@ long getAndClearDroppedCount() {
709718

710719
FlushPayload getPayload() {
711720
Event[] eventsOut = events.toArray(new Event[events.size()]);
712-
EventSummarizer.EventSummary summary = summarizer.getSummaryAndReset();
713-
return new FlushPayload(eventsOut, summary);
721+
List<EventSummarizer.EventSummary> summaries = summarizer.getSummariesAndReset();
722+
return new FlushPayload(eventsOut, summaries);
714723
}
715724

716725
void clear() {
@@ -721,11 +730,11 @@ void clear() {
721730

722731
private static final class FlushPayload {
723732
final Event[] events;
724-
final EventSummary summary;
733+
final List<EventSummary> summaries;
725734

726-
FlushPayload(Event[] events, EventSummary summary) {
735+
FlushPayload(Event[] events, List<EventSummary> summaries) {
727736
this.events = events;
728-
this.summary = summary;
737+
this.summaries = summaries;
729738
}
730739
}
731740

@@ -774,7 +783,7 @@ public void run() {
774783
try {
775784
ByteArrayOutputStream buffer = new ByteArrayOutputStream(INITIAL_OUTPUT_BUFFER_SIZE);
776785
Writer writer = new BufferedWriter(new OutputStreamWriter(buffer, Charset.forName("UTF-8")), INITIAL_OUTPUT_BUFFER_SIZE);
777-
int outputEventCount = formatter.writeOutputEvents(payload.events, payload.summary, writer);
786+
int outputEventCount = formatter.writeOutputEvents(payload.events, payload.summaries, writer);
778787
writer.flush();
779788
EventSender.Result result = eventsConfig.eventSender.sendAnalyticsEvents(
780789
buffer.toByteArray(),

lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import java.io.IOException;
1313
import java.io.Writer;
14+
import java.util.List;
1415
import java.util.Map;
1516

1617
import static com.launchdarkly.sdk.internal.GsonHelpers.gsonInstance;
@@ -33,7 +34,7 @@ final class EventOutputFormatter {
3334
config.privateAttributes.toArray(new AttributeRef[config.privateAttributes.size()]));
3435
}
3536

36-
int writeOutputEvents(Event[] events, EventSummarizer.EventSummary summary, Writer writer) throws IOException {
37+
int writeOutputEvents(Event[] events, List<EventSummarizer.EventSummary> summaries, Writer writer) throws IOException {
3738
int count = 0;
3839
JsonWriter jsonWriter = new JsonWriter(writer);
3940
jsonWriter.beginArray();
@@ -42,9 +43,11 @@ int writeOutputEvents(Event[] events, EventSummarizer.EventSummary summary, Writ
4243
count++;
4344
}
4445
}
45-
if (!summary.isEmpty()) {
46-
writeSummaryEvent(summary, jsonWriter);
47-
count++;
46+
for (EventSummarizer.EventSummary summary : summaries) {
47+
if (!summary.isEmpty()) {
48+
writeSummaryEvent(summary, jsonWriter);
49+
count++;
50+
}
4851
}
4952
jsonWriter.endArray();
5053
jsonWriter.flush();
@@ -234,6 +237,11 @@ private void writeSummaryEvent(EventSummarizer.EventSummary summary, JsonWriter
234237
jw.name("endDate");
235238
jw.value(summary.endDate);
236239

240+
// Include context if present (per-context summarization)
241+
if (summary.context != null) {
242+
writeContext(summary.context, jw, true); // redact anonymous attributes
243+
}
244+
237245
jw.name("features");
238246
jw.beginObject();
239247

lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventSummarizer.java

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,15 @@
1616
*/
1717
final class EventSummarizer {
1818
private EventSummary eventsState;
19-
19+
private final LDContext context; // nullable - only set for per-context summarization
20+
2021
EventSummarizer() {
21-
this.eventsState = new EventSummary();
22+
this(null);
23+
}
24+
25+
EventSummarizer(LDContext context) {
26+
this.context = context;
27+
this.eventsState = new EventSummary(context);
2228
}
2329

2430
/**
@@ -76,22 +82,29 @@ boolean isEmpty() {
7682
}
7783

7884
void clear() {
79-
eventsState = new EventSummary();
85+
eventsState = new EventSummary(context);
8086
}
8187

8288
static final class EventSummary {
8389
final Map<String, FlagInfo> counters;
8490
long startDate;
8591
long endDate;
86-
92+
final LDContext context; // nullable for backward compatibility
93+
8794
EventSummary() {
88-
counters = new HashMap<>();
95+
this((LDContext) null);
96+
}
97+
98+
EventSummary(LDContext context) {
99+
this.counters = new HashMap<>();
100+
this.context = context;
89101
}
90102

91103
EventSummary(EventSummary from) {
92104
counters = new HashMap<>(from.counters);
93105
startDate = from.startDate;
94106
endDate = from.endDate;
107+
context = from.context;
95108
}
96109

97110
boolean isEmpty() {
@@ -142,7 +155,8 @@ void noteTimestamp(long time) {
142155
public boolean equals(Object other) {
143156
if (other instanceof EventSummary) {
144157
EventSummary o = (EventSummary)other;
145-
return o.counters.equals(counters) && startDate == o.startDate && endDate == o.endDate;
158+
return o.counters.equals(counters) && startDate == o.startDate && endDate == o.endDate &&
159+
Objects.equals(context, o.context);
146160
}
147161
return false;
148162
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package com.launchdarkly.sdk.internal.events;
2+
3+
import com.launchdarkly.sdk.LDContext;
4+
import com.launchdarkly.sdk.LDValue;
5+
import com.launchdarkly.sdk.internal.events.EventSummarizer.EventSummary;
6+
7+
import java.util.List;
8+
9+
/**
10+
* Interface for event summarization strategies. Implementations can provide either
11+
* single-summary (aggregated) or per-context summary behavior.
12+
* <p>
13+
* Note that implementations are deliberately not thread-safe, as they should always
14+
* be called from EventProcessor's single message-processing thread.
15+
*/
16+
interface EventSummarizerInterface {
17+
/**
18+
* Adds information about an evaluation to the summary.
19+
*
20+
* @param timestamp the millisecond timestamp
21+
* @param flagKey the flag key
22+
* @param flagVersion the flag version, or -1 if the flag is unknown
23+
* @param variation the result variation, or -1 if none
24+
* @param value the result value
25+
* @param defaultValue the application default value
26+
* @param context the evaluation context
27+
*/
28+
void summarizeEvent(
29+
long timestamp,
30+
String flagKey,
31+
int flagVersion,
32+
int variation,
33+
LDValue value,
34+
LDValue defaultValue,
35+
LDContext context
36+
);
37+
38+
/**
39+
* Gets all current summarized event data and resets the state to empty.
40+
*
41+
* @return list of summary states (may contain one or many summaries depending on implementation)
42+
*/
43+
List<EventSummary> getSummariesAndReset();
44+
45+
/**
46+
* Restores the summarizer state from a previous snapshot. This is used when a flush
47+
* operation fails, and we need to keep the summary data for the next attempt.
48+
*
49+
* @param previousSummaries the list of summaries to restore
50+
*/
51+
void restoreTo(List<EventSummary> previousSummaries);
52+
53+
/**
54+
* Returns true if there is no summary data.
55+
*
56+
* @return true if the state is empty
57+
*/
58+
boolean isEmpty();
59+
60+
/**
61+
* Clears all summary data.
62+
*/
63+
void clear();
64+
}

lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,11 @@ public final class EventsConfiguration {
3030
final boolean initiallyInBackground;
3131
final boolean initiallyOffline;
3232
final List<AttributeRef> privateAttributes;
33+
final boolean perContextSummarization;
3334

3435
/**
3536
* Creates an instance.
36-
*
37+
*
3738
* @param allAttributesPrivate true if all attributes are private
3839
* @param capacity event buffer capacity (if zero or negative, a value of 1 is used to prevent errors)
3940
* @param contextDeduplicator optional EventContextDeduplicator; null for client-side SDK
@@ -63,6 +64,45 @@ public EventsConfiguration(
6364
boolean initiallyOffline,
6465
Collection<AttributeRef> privateAttributes
6566
) {
67+
this(allAttributesPrivate, capacity, contextDeduplicator, diagnosticRecordingIntervalMillis,
68+
diagnosticStore, eventSender, eventSendingThreadPoolSize, eventsUri, flushIntervalMillis,
69+
initiallyInBackground, initiallyOffline, privateAttributes, false);
70+
}
71+
72+
/**
73+
* Creates an instance.
74+
*
75+
* @param allAttributesPrivate true if all attributes are private
76+
* @param capacity event buffer capacity (if zero or negative, a value of 1 is used to prevent errors)
77+
* @param contextDeduplicator optional EventContextDeduplicator; null for client-side SDK
78+
* @param diagnosticRecordingIntervalMillis diagnostic recording interval
79+
* @param diagnosticStore optional DiagnosticStore; null if diagnostics are disabled
80+
* @param eventSender event delivery component; must not be null
81+
* @param eventSendingThreadPoolSize number of worker threads for event delivery; zero to use the default
82+
* @param eventsUri events base URI
83+
* @param flushIntervalMillis event flush interval
84+
* @param initiallyInBackground true if we should start out in background mode (see
85+
* {@link DefaultEventProcessor#setInBackground(boolean)})
86+
* @param initiallyOffline true if we should start out in offline mode (see
87+
* {@link DefaultEventProcessor#setOffline(boolean)})
88+
* @param privateAttributes list of private attribute references; may be null
89+
* @param perContextSummarization true to generate separate summary events per context
90+
*/
91+
public EventsConfiguration(
92+
boolean allAttributesPrivate,
93+
int capacity,
94+
EventContextDeduplicator contextDeduplicator,
95+
long diagnosticRecordingIntervalMillis,
96+
DiagnosticStore diagnosticStore,
97+
EventSender eventSender,
98+
int eventSendingThreadPoolSize,
99+
URI eventsUri,
100+
long flushIntervalMillis,
101+
boolean initiallyInBackground,
102+
boolean initiallyOffline,
103+
Collection<AttributeRef> privateAttributes,
104+
boolean perContextSummarization
105+
) {
66106
super();
67107
this.allAttributesPrivate = allAttributesPrivate;
68108
this.capacity = capacity >= 0 ? capacity : 1;
@@ -77,5 +117,6 @@ public EventsConfiguration(
77117
this.initiallyInBackground = initiallyInBackground;
78118
this.initiallyOffline = initiallyOffline;
79119
this.privateAttributes = privateAttributes == null ? Collections.emptyList() : new ArrayList<>(privateAttributes);
120+
this.perContextSummarization = perContextSummarization;
80121
}
81122
}

0 commit comments

Comments
 (0)