Skip to content

Commit 05f7d4e

Browse files
authored
feat: Add support for per-context summary events. (#126)
1 parent 7f488c2 commit 05f7d4e

24 files changed

+528
-14
lines changed

src/EventProcessor.js

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
const EventSender = require('./EventSender');
2-
const EventSummarizer = require('./EventSummarizer');
2+
const MultiEventSummarizer = require('./MultiEventSummarizer');
33
const ContextFilter = require('./ContextFilter');
44
const errors = require('./errors');
55
const messages = require('./messages');
@@ -17,8 +17,8 @@ function EventProcessor(
1717
const processor = {};
1818
const eventSender = sender || EventSender(platform, environmentId, options);
1919
const mainEventsUrl = utils.appendUrlPath(options.eventsUrl, '/events/bulk/' + environmentId);
20-
const summarizer = EventSummarizer();
2120
const contextFilter = ContextFilter(options);
21+
const summarizer = MultiEventSummarizer(contextFilter);
2222
const samplingInterval = options.samplingInterval;
2323
const eventCapacity = options.eventCapacity;
2424
const flushInterval = options.flushInterval;
@@ -117,17 +117,19 @@ function EventProcessor(
117117
}
118118
};
119119

120-
processor.flush = function() {
120+
processor.flush = async function() {
121121
if (disabled) {
122122
return Promise.resolve();
123123
}
124124
const eventsToSend = queue;
125-
const summary = summarizer.getSummary();
126-
summarizer.clearSummary();
127-
if (summary) {
128-
summary.kind = 'summary';
129-
eventsToSend.push(summary);
130-
}
125+
const summaries = summarizer.getSummaries();
126+
127+
summaries.forEach(summary => {
128+
if (Object.keys(summary.features).length) {
129+
eventsToSend.push(summary);
130+
}
131+
});
132+
131133
if (diagnosticsAccumulator) {
132134
// For diagnostic events, we record how many events were in the queue at the last flush (since "how
133135
// many events happened to be in the queue at the moment we decided to send a diagnostic event" would

src/EventSummarizer.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ function EventSummarizer() {
8989
startDate,
9090
endDate,
9191
features: flagsOut,
92+
kind: 'summary',
9293
};
9394
};
9495

src/MultiEventSummarizer.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
const canonicalize = require('./canonicalize');
2+
const EventSummarizer = require('./EventSummarizer');
3+
4+
/**
5+
* Construct a multi-event summarizer. This summarizer produces a summary event for each unique context.
6+
* @param {{filter: (context: any) => any}} contextFilter
7+
*/
8+
function MultiEventSummarizer(contextFilter) {
9+
let summarizers = {};
10+
let contexts = {};
11+
12+
/**
13+
* Summarize the given event.
14+
* @param {{
15+
* kind: string,
16+
* context?: any,
17+
* }} event
18+
*/
19+
function summarizeEvent(event) {
20+
if (event.kind === 'feature') {
21+
const key = canonicalize(event.context);
22+
if (!key) {
23+
return;
24+
}
25+
26+
let summarizer = summarizers[key];
27+
if (!summarizer) {
28+
summarizers[key] = EventSummarizer();
29+
summarizer = summarizers[key];
30+
contexts[key] = event.context;
31+
}
32+
33+
summarizer.summarizeEvent(event);
34+
}
35+
}
36+
37+
/**
38+
* Get the summaries of the events that have been summarized.
39+
* @returns {any[]}
40+
*/
41+
function getSummaries() {
42+
const summarizersToFlush = summarizers;
43+
const contextsForSummaries = contexts;
44+
45+
summarizers = {};
46+
contexts = {};
47+
return Object.entries(summarizersToFlush).map(([key, summarizer]) => {
48+
const summary = summarizer.getSummary();
49+
summary.context = contextFilter.filter(contextsForSummaries[key]);
50+
return summary;
51+
});
52+
}
53+
54+
return {
55+
summarizeEvent,
56+
getSummaries,
57+
};
58+
}
59+
60+
module.exports = MultiEventSummarizer;

src/__tests__/EventProcessor-test.js

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,73 @@ describe.each([
485485
});
486486
});
487487

488+
it('generates separate summary events for different contexts', async () => {
489+
await withProcessorAndSender(defaultConfig, async (ep, mockEventSender) => {
490+
const context1 = { key: 'user1', kind: 'user' };
491+
const context2 = { key: 'user2', kind: 'user' };
492+
493+
// Create feature events for two different contexts
494+
const event1 = {
495+
kind: 'feature',
496+
creationDate: 1000,
497+
context: context1,
498+
key: 'flag1',
499+
version: 11,
500+
variation: 1,
501+
value: 'value1',
502+
default: 'default1',
503+
trackEvents: false,
504+
};
505+
506+
const event2 = {
507+
kind: 'feature',
508+
creationDate: 1000,
509+
context: context2,
510+
key: 'flag2',
511+
version: 22,
512+
variation: 2,
513+
value: 'value2',
514+
default: 'default2',
515+
trackEvents: false,
516+
};
517+
518+
ep.enqueue(event1);
519+
ep.enqueue(event2);
520+
await ep.flush();
521+
522+
expect(mockEventSender.calls.length()).toEqual(1);
523+
const output = (await mockEventSender.calls.take()).events;
524+
525+
// Should have two summary events, one for each context
526+
expect(output.length).toEqual(2);
527+
528+
// Find the summary event for each context
529+
const summary1 = output.find(e => e.context.key === 'user1');
530+
const summary2 = output.find(e => e.context.key === 'user2');
531+
532+
// Verify each summary event has the correct context and flag data
533+
expect(summary1).toBeDefined();
534+
expect(summary1.context).toEqual(context1);
535+
expect(summary1.features).toEqual({
536+
flag1: {
537+
contextKinds: ['user'],
538+
default: 'default1',
539+
counters: [{ version: 11, variation: 1, value: 'value1', count: 1 }],
540+
},
541+
});
542+
543+
expect(summary2).toBeDefined();
544+
expect(summary2.context).toEqual(context2);
545+
expect(summary2.features).toEqual({
546+
flag2: {
547+
contextKinds: ['user'],
548+
default: 'default2',
549+
counters: [{ version: 22, variation: 2, value: 'value2', count: 1 }],
550+
},
551+
});
552+
});
553+
});
554+
488555
describe('interaction with diagnostic events', () => {
489556
it('sets eventsInLastBatch on flush', async () => {
490557
const e0 = { kind: 'custom', creationDate: 1000, context: eventContext, key: 'key0' };
@@ -525,5 +592,50 @@ describe.each([
525592
expect(diagnosticAccumulator.getProps().droppedEvents).toEqual(2);
526593
});
527594
});
595+
596+
it('filters context in summary events', async () => {
597+
const event = {
598+
kind: 'feature',
599+
creationDate: 1000,
600+
context: eventContext,
601+
key: 'flagkey',
602+
version: 11,
603+
variation: 1,
604+
value: 'value',
605+
default: 'default',
606+
trackEvents: true,
607+
};
608+
609+
// Configure with allAttributesPrivate set to true
610+
const config = { ...defaultConfig, allAttributesPrivate: true };
611+
612+
const sender = MockEventSender();
613+
const ep = EventProcessor(platform, config, envId, null, null, sender);
614+
try {
615+
ep.enqueue(event);
616+
await ep.flush();
617+
618+
expect(sender.calls.length()).toEqual(1);
619+
const output = (await sender.calls.take()).events;
620+
expect(output.length).toEqual(2);
621+
622+
// Verify the feature event has filtered context
623+
checkFeatureEvent(output[0], event, false, filteredContext);
624+
625+
// Verify the summary event has filtered context
626+
const summaryEvent = output[1];
627+
checkSummaryEvent(summaryEvent);
628+
expect(summaryEvent.context).toEqual(filteredContext);
629+
expect(summaryEvent.features).toEqual({
630+
flagkey: {
631+
contextKinds: ['user'],
632+
default: 'default',
633+
counters: [{ version: 11, variation: 1, value: 'value', count: 1 }],
634+
},
635+
});
636+
} finally {
637+
ep.stop();
638+
}
639+
});
528640
});
529641
});

src/__tests__/LDClient-test.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -672,7 +672,9 @@ describe('LDClient', () => {
672672
await client.waitForInitialization(5);
673673
});
674674

675-
expect(eventsServer.requests.length()).toEqual(1);
675+
// Flushing is an async operation, so we cannot ensure that the requests are made by
676+
// the time we reach this point. If we await the nextRequest(), then it will catch
677+
// whatever was flushed.
676678
const req = await eventsServer.nextRequest();
677679
const data = JSON.parse(req.body);
678680
expect(data.length).toEqual(1);
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
const MultiEventSummarizer = require('../MultiEventSummarizer');
2+
const ContextFilter = require('../ContextFilter');
3+
4+
function makeEvent(key, version, variation, value, defaultVal, context) {
5+
return {
6+
kind: 'feature',
7+
creationDate: 1000,
8+
key: key,
9+
version: version,
10+
context: context,
11+
variation: variation,
12+
value: value,
13+
default: defaultVal,
14+
};
15+
}
16+
17+
describe('given a multi-event summarizer and context filter', () => {
18+
let summarizer;
19+
let contextFilter;
20+
21+
beforeEach(() => {
22+
contextFilter = ContextFilter(false, []);
23+
summarizer = MultiEventSummarizer(contextFilter);
24+
});
25+
26+
it('creates new summarizer for new context hash', async () => {
27+
const context = { kind: 'user', key: 'user1' };
28+
const event = { kind: 'feature', context };
29+
30+
summarizer.summarizeEvent(event);
31+
32+
const summaries = await summarizer.getSummaries();
33+
expect(summaries).toHaveLength(1);
34+
});
35+
36+
it('uses existing summarizer for same context hash', async () => {
37+
const context = { kind: 'user', key: 'user1' };
38+
const event1 = { kind: 'feature', context, value: 'value1' };
39+
const event2 = { kind: 'feature', context, value: 'value2' };
40+
41+
summarizer.summarizeEvent(event1);
42+
summarizer.summarizeEvent(event2);
43+
44+
const summaries = await summarizer.getSummaries();
45+
expect(summaries).toHaveLength(1);
46+
});
47+
48+
it('ignores non-feature events', async () => {
49+
const context = { kind: 'user', key: 'user1' };
50+
const event = { kind: 'identify', context };
51+
52+
summarizer.summarizeEvent(event);
53+
54+
const summaries = await summarizer.getSummaries();
55+
expect(summaries).toHaveLength(0);
56+
});
57+
58+
it('handles multiple different contexts', async () => {
59+
const context1 = { kind: 'user', key: 'user1' };
60+
const context2 = { kind: 'user', key: 'user2' };
61+
const event1 = { kind: 'feature', context: context1 };
62+
const event2 = { kind: 'feature', context: context2 };
63+
64+
summarizer.summarizeEvent(event1);
65+
summarizer.summarizeEvent(event2);
66+
67+
const summaries = await summarizer.getSummaries();
68+
expect(summaries).toHaveLength(2);
69+
});
70+
71+
it('automatically clears summaries when summarized', async () => {
72+
const context = { kind: 'user', key: 'user1' };
73+
const event = { kind: 'feature', context };
74+
75+
summarizer.summarizeEvent(event);
76+
77+
const summariesA = await summarizer.getSummaries();
78+
const summariesB = await summarizer.getSummaries();
79+
expect(summariesA).toHaveLength(1);
80+
expect(summariesB).toHaveLength(0);
81+
});
82+
83+
it('increments counters for feature events across multiple contexts', async () => {
84+
const context1 = { kind: 'user', key: 'user1' };
85+
const context2 = { kind: 'user', key: 'user2' };
86+
87+
// Events for context1 (using values 100-199)
88+
const event1 = makeEvent('key1', 11, 1, 100, 111, context1);
89+
const event2 = makeEvent('key1', 11, 2, 150, 111, context1);
90+
const event3 = makeEvent('key2', 22, 1, 199, 222, context1);
91+
92+
// Events for context2 (using values 200-299)
93+
const event4 = makeEvent('key1', 11, 1, 200, 211, context2);
94+
const event5 = makeEvent('key1', 11, 2, 250, 211, context2);
95+
const event6 = makeEvent('key2', 22, 1, 299, 222, context2);
96+
97+
summarizer.summarizeEvent(event1);
98+
summarizer.summarizeEvent(event2);
99+
summarizer.summarizeEvent(event3);
100+
summarizer.summarizeEvent(event4);
101+
summarizer.summarizeEvent(event5);
102+
summarizer.summarizeEvent(event6);
103+
104+
const summaries = await summarizer.getSummaries();
105+
expect(summaries).toHaveLength(2);
106+
107+
// Sort summaries by context key to make assertions consistent
108+
summaries.sort((a, b) => a.context.key.localeCompare(b.context.key));
109+
110+
// Verify first context's summary (user1, values 100-199)
111+
const summary1 = summaries[0];
112+
summary1.features.key1.counters.sort((a, b) => a.value - b.value);
113+
expect(summary1.features).toEqual({
114+
key1: {
115+
contextKinds: ['user'],
116+
default: 111,
117+
counters: [
118+
{ value: 100, variation: 1, version: 11, count: 1 },
119+
{ value: 150, variation: 2, version: 11, count: 1 },
120+
],
121+
},
122+
key2: {
123+
contextKinds: ['user'],
124+
default: 222,
125+
counters: [{ value: 199, variation: 1, version: 22, count: 1 }],
126+
},
127+
});
128+
129+
// Verify second context's summary (user2, values 200-299)
130+
const summary2 = summaries[1];
131+
summary2.features.key1.counters.sort((a, b) => a.value - b.value);
132+
expect(summary2.features).toEqual({
133+
key1: {
134+
contextKinds: ['user'],
135+
default: 211,
136+
counters: [
137+
{ value: 200, variation: 1, version: 11, count: 1 },
138+
{ value: 250, variation: 2, version: 11, count: 1 },
139+
],
140+
},
141+
key2: {
142+
contextKinds: ['user'],
143+
default: 222,
144+
counters: [{ value: 299, variation: 1, version: 22, count: 1 }],
145+
},
146+
});
147+
});
148+
});

0 commit comments

Comments
 (0)