Skip to content

Commit 65cf345

Browse files
authored
feat: Support for per-context summary events. (#243)
Adds support for per-context summary events. <!-- CURSOR_SUMMARY --> > [!NOTE] > **Medium Risk** > Changes analytics event shape and batching behavior (multiple summary events, optional embedded context), which could affect downstream ingestion and privacy expectations if misconfigured or if context equality differs from expectations. > > **Overview** > Adds **per-context summary event** support by changing `EventSummarizer` to maintain separate accumulators per unique `LDContext` (with an option `summariesPerContext` to fall back to a single global summary). > > `DefaultEventProcessor` now flushes *multiple* summary events and `SummaryEvent`/`SummaryEventSerialization` gain optional `context` output, serialized with event-style privacy filtering. Contract-test capabilities and common-client dependency are updated, and tests are expanded/updated for the new multi-summary behavior and context handling. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d2eb218. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 1e84e7a commit 65cf345

7 files changed

Lines changed: 433 additions & 50 deletions

File tree

apps/flutter_client_contract_test_service/bin/contract_test_service.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class TestApiImpl extends SdkTestApi {
2525
'context-comparison',
2626
'inline-context-all',
2727
'anonymous-redaction',
28+
'client-per-context-summaries',
2829
'client-prereq-events',
2930
'auto-env-attributes',
3031
];

packages/common/lib/src/events/default_event_processor.dart

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const _eventSchema = '4';
2424
// along with the corresponding context de-duplication.
2525

2626
final class DefaultEventProcessor implements EventProcessor {
27-
final _eventSummarizer = EventSummarizer();
27+
final EventSummarizer _eventSummarizer;
2828
final LDLogger _logger;
2929
final int _eventCapacity;
3030
final Duration _flushInterval;
@@ -60,6 +60,7 @@ final class DefaultEventProcessor implements EventProcessor {
6060
required Duration diagnosticRecordingInterval,
6161
required bool allAttributesPrivate,
6262
required Set<AttributeReference> globalPrivateAttributes,
63+
bool summariesPerContext = true,
6364
DiagnosticsManager? diagnosticsManager})
6465
: _logger = logger.subLogger('EventProcessor'),
6566
_eventCapacity = eventCapacity,
@@ -68,7 +69,9 @@ final class DefaultEventProcessor implements EventProcessor {
6869
_allAttributesPrivate = allAttributesPrivate,
6970
_globalPrivateAttributes = globalPrivateAttributes,
7071
_diagnosticsManager = diagnosticsManager,
71-
_diagnosticRecordingInterval = diagnosticRecordingInterval {
72+
_diagnosticRecordingInterval = diagnosticRecordingInterval,
73+
_eventSummarizer =
74+
EventSummarizer(summariesPerContext: summariesPerContext) {
7275
_eventsUri = Uri.parse(appendPath(endpoints.events, analyticsEventsPath));
7376

7477
_diagnosticEventsUri =
@@ -160,11 +163,16 @@ final class DefaultEventProcessor implements EventProcessor {
160163
final eventsToFlush = _eventBuffer;
161164
_eventBuffer = [];
162165

163-
final summaryEvent = _eventSummarizer.createEventAndReset();
166+
final summaryEvents = _eventSummarizer.createEventsAndReset();
164167

165-
if (summaryEvent != null) {
166-
eventsToFlush.add(SummaryEventSerialization.toJson(summaryEvent));
168+
for (final summaryEvent in summaryEvents) {
169+
eventsToFlush.add(SummaryEventSerialization.toJson(
170+
summaryEvent,
171+
allAttributesPrivate: _allAttributesPrivate,
172+
globalPrivateAttributes: _globalPrivateAttributes,
173+
));
167174
}
175+
168176
if (eventsToFlush.isEmpty) {
169177
return;
170178
}

packages/common/lib/src/events/event_summarizer.dart

Lines changed: 82 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import '../ld_context.dart';
12
import '../ld_value.dart';
23
import 'events.dart';
34

@@ -37,45 +38,39 @@ final class _SummaryCounter {
3738
}
3839
}
3940

40-
/// Tracks evaluation events in order to generate summary events.
41-
final class EventSummarizer {
41+
/// Accumulates summary statistics.
42+
/// When [includeContextInSummary] is true, the context is included in the generated
43+
/// summary event. When false, statistics are aggregated without context information.
44+
final class _ContextAccumulator {
4245
int _startDate = 0;
4346
int _endDate = 0;
44-
47+
final LDContext context;
48+
final bool includeContextInSummary;
4549
final Map<FlagKey, _SummaryCounter> _features = {};
4650

47-
void summarize(EvalEvent event) {
48-
if (!_features.containsKey(event.flagKey)) {
49-
_features[event.flagKey] = _SummaryCounter(event.defaultValue);
50-
}
51-
_features[event.flagKey]!.count(
52-
event.evaluationDetail.variationIndex,
53-
event.version,
54-
event.evaluationDetail.value,
55-
event.context.attributesByKind.keys.toSet());
56-
57-
if (event.creationDate.millisecondsSinceEpoch < _startDate ||
58-
_startDate == 0) {
59-
_startDate = event.creationDate.millisecondsSinceEpoch;
60-
}
61-
if (event.creationDate.millisecondsSinceEpoch > _endDate) {
62-
_endDate = event.creationDate.millisecondsSinceEpoch;
51+
_ContextAccumulator(this.context, {required this.includeContextInSummary});
52+
53+
void count(FlagKey flagKey, LDValue defaultValue, Variation variation,
54+
Version version, LDValue value, Set<String> contextKinds) {
55+
if (!_features.containsKey(flagKey)) {
56+
_features[flagKey] = _SummaryCounter(defaultValue);
6357
}
58+
_features[flagKey]!.count(variation, version, value, contextKinds);
6459
}
6560

66-
void _clear() {
67-
_startDate = 0;
68-
_endDate = 0;
69-
_features.clear();
61+
void updateDates(DateTime eventDate) {
62+
final timestamp = eventDate.millisecondsSinceEpoch;
63+
if (timestamp < _startDate || _startDate == 0) {
64+
_startDate = timestamp;
65+
}
66+
if (timestamp > _endDate) {
67+
_endDate = timestamp;
68+
}
7069
}
7170

72-
SummaryEvent? createEventAndReset() {
71+
SummaryEvent createSummary() {
7372
final features = <String, FlagSummary>{};
7473

75-
if (_features.isEmpty) {
76-
return null;
77-
}
78-
7974
for (var feature in _features.entries) {
8075
final counters = <FlagCounter>[];
8176

@@ -100,9 +95,65 @@ final class EventSummarizer {
10095
final startDate = DateTime.fromMillisecondsSinceEpoch(_startDate);
10196
final endDate = DateTime.fromMillisecondsSinceEpoch(_endDate);
10297

103-
_clear();
104-
10598
return SummaryEvent(
106-
startDate: startDate, endDate: endDate, features: features);
99+
startDate: startDate,
100+
endDate: endDate,
101+
features: features,
102+
context: includeContextInSummary ? context : null,
103+
);
104+
}
105+
}
106+
107+
/// Tracks evaluation events in order to generate summary events.
108+
/// When [summariesPerContext] is true, generates one summary event per unique context.
109+
/// When false, generates a single global summary event without context information.
110+
final class EventSummarizer {
111+
final bool summariesPerContext;
112+
final Map<LDContext?, _ContextAccumulator> _accumulatorsByContext = {};
113+
114+
EventSummarizer({this.summariesPerContext = true});
115+
116+
void summarize(EvalEvent event) {
117+
// Skip invalid contexts
118+
if (!event.context.valid) {
119+
return;
120+
}
121+
122+
// When per-context summaries are disabled, use null as the key so all
123+
// events go into a single accumulator. When enabled, use the actual context
124+
// as the key so each unique context gets its own accumulator.
125+
final contextKey = summariesPerContext ? event.context : null;
126+
127+
// Get or create accumulator for this context key
128+
final accumulator = _accumulatorsByContext.putIfAbsent(
129+
contextKey,
130+
() => _ContextAccumulator(event.context,
131+
includeContextInSummary: summariesPerContext),
132+
);
133+
134+
// Update the accumulator
135+
accumulator.count(
136+
event.flagKey,
137+
event.defaultValue,
138+
event.evaluationDetail.variationIndex,
139+
event.version,
140+
event.evaluationDetail.value,
141+
event.context.attributesByKind.keys.toSet(),
142+
);
143+
accumulator.updateDates(event.creationDate);
144+
}
145+
146+
List<SummaryEvent> createEventsAndReset() {
147+
if (_accumulatorsByContext.isEmpty) {
148+
return [];
149+
}
150+
151+
final events = _accumulatorsByContext.values
152+
.map((accumulator) => accumulator.createSummary())
153+
.toList();
154+
155+
_accumulatorsByContext.clear();
156+
157+
return events;
107158
}
108159
}

packages/common/lib/src/events/events.dart

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,12 +144,17 @@ final class SummaryEvent {
144144
final DateTime startDate;
145145
final DateTime endDate;
146146
final Map<String, FlagSummary> features;
147+
final LDContext? context;
147148

148-
SummaryEvent(
149-
{required this.startDate, required this.endDate, required this.features});
149+
SummaryEvent({
150+
required this.startDate,
151+
required this.endDate,
152+
required this.features,
153+
this.context,
154+
});
150155

151156
@override
152157
String toString() {
153-
return 'SummaryEvent{startDate: $startDate, endDate: $endDate, features: $features}';
158+
return 'SummaryEvent{startDate: $startDate, endDate: $endDate, features: $features, context: $context}';
154159
}
155160
}

packages/common/lib/src/serialization/event_serialization.dart

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,9 @@ final class _FlagSummarySerialization {
110110
}
111111

112112
final class SummaryEventSerialization {
113-
static Map<String, dynamic> toJson(SummaryEvent event) {
113+
static Map<String, dynamic> toJson(SummaryEvent event,
114+
{required bool allAttributesPrivate,
115+
required Set<AttributeReference> globalPrivateAttributes}) {
114116
final json = <String, dynamic>{};
115117

116118
json['kind'] = 'summary';
@@ -119,6 +121,19 @@ final class SummaryEventSerialization {
119121
json['features'] = event.features.map(
120122
(key, value) => MapEntry(key, _FlagSummarySerialization.toJson(value)));
121123

124+
// Serialize context with event-style privacy filtering (if present)
125+
if (event.context != null) {
126+
final contextJson = LDContextSerialization.toJson(
127+
event.context!,
128+
isEvent: true,
129+
allAttributesPrivate: allAttributesPrivate,
130+
globalPrivateAttributes: globalPrivateAttributes,
131+
);
132+
if (contextJson != null) {
133+
json['context'] = contextJson;
134+
}
135+
}
136+
122137
return json;
123138
}
124139
}

0 commit comments

Comments
 (0)