Skip to content

Commit da31436

Browse files
authored
feat: adds support for individual flag change listeners (#608)
**Requirements** - [x] I have added test coverage for new or changed functionality - [x] I have followed the repository's [pull request submission guidelines](../blob/main/CONTRIBUTING.md#submitting-pull-requests) - [x] I have validated my changes against all supported platform versions **Related issues** SDK-708
1 parent 4e5dbee commit da31436

File tree

3 files changed

+190
-6
lines changed

3 files changed

+190
-6
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { AutoEnvAttributes, clone, type LDContext, LDLogger } from '@launchdarkly/js-sdk-common';
2+
3+
import LDClientImpl from '../src/LDClientImpl';
4+
import LDEmitter from '../src/LDEmitter';
5+
import { Flags, PatchFlag } from '../src/types';
6+
import { createBasicPlatform } from './createBasicPlatform';
7+
import * as mockResponseJson from './evaluation/mockResponse.json';
8+
import { MockEventSource } from './streaming/LDClientImpl.mocks';
9+
import { makeTestDataManagerFactory } from './TestDataManager';
10+
11+
let mockPlatform: ReturnType<typeof createBasicPlatform>;
12+
let logger: LDLogger;
13+
14+
beforeEach(() => {
15+
mockPlatform = createBasicPlatform();
16+
logger = {
17+
error: jest.fn(),
18+
warn: jest.fn(),
19+
info: jest.fn(),
20+
debug: jest.fn(),
21+
};
22+
});
23+
24+
const testSdkKey = 'test-sdk-key';
25+
const context: LDContext = { kind: 'org', key: 'Testy Pizza' };
26+
const flagStorageKey = 'LaunchDarkly_1234567890123456_1234567890123456';
27+
const indexStorageKey = 'LaunchDarkly_1234567890123456_ContextIndex';
28+
let ldc: LDClientImpl;
29+
let mockEventSource: MockEventSource;
30+
let emitter: LDEmitter;
31+
let defaultPutResponse: Flags;
32+
let defaultFlagKeys: string[];
33+
34+
// Promisify on.change listener so we can await it in tests.
35+
const onChangePromise = () =>
36+
new Promise<string[]>((res) => {
37+
ldc.on('change', (_context: LDContext, changes: string[]) => {
38+
res(changes);
39+
});
40+
});
41+
42+
describe('sdk-client change emitter', () => {
43+
beforeEach(() => {
44+
jest.useFakeTimers();
45+
defaultPutResponse = clone<Flags>(mockResponseJson);
46+
defaultFlagKeys = Object.keys(defaultPutResponse);
47+
48+
(mockPlatform.storage.get as jest.Mock).mockImplementation((storageKey: string) => {
49+
switch (storageKey) {
50+
case flagStorageKey:
51+
return JSON.stringify(defaultPutResponse);
52+
case indexStorageKey:
53+
return undefined;
54+
default:
55+
return undefined;
56+
}
57+
});
58+
59+
ldc = new LDClientImpl(
60+
testSdkKey,
61+
AutoEnvAttributes.Disabled,
62+
mockPlatform,
63+
{
64+
logger,
65+
sendEvents: false,
66+
},
67+
makeTestDataManagerFactory(testSdkKey, mockPlatform),
68+
);
69+
70+
// @ts-ignore
71+
emitter = ldc.emitter;
72+
jest.spyOn(emitter as LDEmitter, 'emit');
73+
});
74+
75+
afterEach(() => {
76+
jest.resetAllMocks();
77+
});
78+
79+
test('initialize from storage emits flags as changed', async () => {
80+
mockPlatform.requests.createEventSource.mockImplementation(
81+
(streamUri: string = '', options: any = {}) => {
82+
mockEventSource = new MockEventSource(streamUri, options);
83+
mockEventSource.simulateError({ status: 404, message: 'error-to-force-cache' });
84+
return mockEventSource;
85+
},
86+
);
87+
88+
const changePromise = onChangePromise();
89+
await ldc.identify(context);
90+
await changePromise;
91+
92+
expect(mockPlatform.storage.get).toHaveBeenCalledWith(flagStorageKey);
93+
94+
expect(emitter.emit).toHaveBeenCalledWith('change', context, defaultFlagKeys);
95+
96+
// a few specific flag changes to verify those are also called
97+
expect(emitter.emit).toHaveBeenCalledWith('change:moonshot-demo', context);
98+
expect(emitter.emit).toHaveBeenCalledWith('change:dev-test-flag', context);
99+
expect(emitter.emit).toHaveBeenCalledWith('change:this-is-a-test', context);
100+
});
101+
102+
test('put should emit changed flags', async () => {
103+
const putResponse = clone<Flags>(defaultPutResponse);
104+
putResponse['dev-test-flag'].version = 999;
105+
putResponse['dev-test-flag'].value = false;
106+
107+
const simulatedEvents = [{ data: JSON.stringify(putResponse) }];
108+
mockPlatform.requests.createEventSource.mockImplementation(
109+
(streamUri: string = '', options: any = {}) => {
110+
mockEventSource = new MockEventSource(streamUri, options);
111+
mockEventSource.simulateEvents('put', simulatedEvents);
112+
return mockEventSource;
113+
},
114+
);
115+
116+
const changePromise = onChangePromise();
117+
await ldc.identify(context);
118+
await changePromise;
119+
await jest.runAllTimersAsync();
120+
121+
expect(emitter.emit).toHaveBeenCalledWith('change', context, ['dev-test-flag']);
122+
expect(emitter.emit).toHaveBeenCalledWith('change:dev-test-flag', context);
123+
});
124+
125+
test('patch should emit changed flags', async () => {
126+
const patchResponse = clone<PatchFlag>(defaultPutResponse['dev-test-flag']);
127+
patchResponse.key = 'dev-test-flag';
128+
patchResponse.value = false;
129+
patchResponse.version += 1;
130+
131+
const putEvents = [{ data: JSON.stringify(defaultPutResponse) }];
132+
const patchEvents = [{ data: JSON.stringify(patchResponse) }];
133+
mockPlatform.requests.createEventSource.mockImplementation(
134+
(streamUri: string = '', options: any = {}) => {
135+
mockEventSource = new MockEventSource(streamUri, options);
136+
mockEventSource.simulateEvents('put', putEvents);
137+
mockEventSource.simulateEvents('patch', patchEvents);
138+
return mockEventSource;
139+
},
140+
);
141+
142+
const changePromise = onChangePromise();
143+
await ldc.identify(context);
144+
await changePromise;
145+
await jest.runAllTimersAsync();
146+
147+
expect(emitter.emit).toHaveBeenCalledWith('change', context, ['dev-test-flag']);
148+
expect(emitter.emit).toHaveBeenCalledWith('change:dev-test-flag', context);
149+
});
150+
151+
test('delete should emit changed flags', async () => {
152+
const deleteResponse = {
153+
key: 'dev-test-flag',
154+
version: defaultPutResponse['dev-test-flag'].version + 1,
155+
};
156+
157+
const putEvents = [{ data: JSON.stringify(defaultPutResponse) }];
158+
const deleteEvents = [{ data: JSON.stringify(deleteResponse) }];
159+
mockPlatform.requests.createEventSource.mockImplementation(
160+
(streamUri: string = '', options: any = {}) => {
161+
mockEventSource = new MockEventSource(streamUri, options);
162+
mockEventSource.simulateEvents('put', putEvents);
163+
mockEventSource.simulateEvents('delete', deleteEvents);
164+
return mockEventSource;
165+
},
166+
);
167+
168+
const changePromise = onChangePromise();
169+
await ldc.identify(context);
170+
await changePromise;
171+
await jest.runAllTimersAsync();
172+
173+
expect(emitter.emit).toHaveBeenCalledWith('change', context, ['dev-test-flag']);
174+
expect(emitter.emit).toHaveBeenCalledWith('change:dev-test-flag', context);
175+
});
176+
});

packages/shared/sdk-client/src/LDClientImpl.ts

+7-5
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,10 @@ export default class LDClientImpl implements LDClient {
107107

108108
this.flagManager.on((context, flagKeys) => {
109109
const ldContext = Context.toLDContext(context);
110-
this.logger.debug(`change: context: ${JSON.stringify(ldContext)}, flags: ${flagKeys}`);
111110
this.emitter.emit('change', ldContext, flagKeys);
111+
flagKeys.forEach((it) => {
112+
this.emitter.emit(`change:${it}`, ldContext);
113+
});
112114
});
113115

114116
this.dataManager = dataManagerFactory(
@@ -249,14 +251,14 @@ export default class LDClientImpl implements LDClient {
249251
return identifyPromise;
250252
}
251253

252-
off(eventName: EventName, listener: Function): void {
253-
this.emitter.off(eventName, listener);
254-
}
255-
256254
on(eventName: EventName, listener: Function): void {
257255
this.emitter.on(eventName, listener);
258256
}
259257

258+
off(eventName: EventName, listener: Function): void {
259+
this.emitter.off(eventName, listener);
260+
}
261+
260262
track(key: string, data?: any, metricValue?: number): void {
261263
if (!this.checkedContext || !this.checkedContext.valid) {
262264
this.logger.warn(ClientMessages.missingContextKeyNoEvent);

packages/shared/sdk-client/src/LDEmitter.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { LDLogger } from '@launchdarkly/js-sdk-common';
22

3-
export type EventName = 'error' | 'change' | 'dataSourceStatus';
3+
type FlagChangeKey = `change:${string}`;
4+
5+
/**
6+
* Type for name of emitted events. 'change' is used for all flag changes. 'change:flag-name-here' is used
7+
* for specific flag changes.
8+
*/
9+
export type EventName = 'change' | FlagChangeKey | 'dataSourceStatus' | 'error';
410

511
/**
612
* Implementation Note: There should not be any default listeners for change events in a client

0 commit comments

Comments
 (0)