Skip to content

Commit 63dc9f9

Browse files
feat: Environment ID support for hooks (#823)
**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 **Describe the solution you've provided** If present, `environmentId` is now passed to the HookRunner in the evaluation series. For streaming connections: - Response headers are now attached to the `open` event (launchdarkly/js-eventsource#33) - `StreamingProcessor` passes these headers to the stream listeners via `processJson`. The listener for the PUT event extracts the `environmentId` from the headers and passes this as initialization metadata to the underlying feature store. For polling connections: - `PollingProcessor` retrieves the response headers via the underlying `Requestor` and extracts the `environmentId` from the headers and passes this as initialization metadata to the underlying feature store. LDClient will then call `getInitMetaData()` on the underlying feature store (if the feature store supports it) when executing a hook and pass `environmentId` in the execution series data. Currently only `InMemoryFeatureStore` has been modified to support initialization metadata. This functionality can be added to other feature stores by modifying `init()` to accept the optional `initMetadata` parameter and implementing the optional `getInitMetadata()` method.
1 parent c486a3d commit 63dc9f9

File tree

28 files changed

+421
-85
lines changed

28 files changed

+421
-85
lines changed

packages/sdk/server-node/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
"dependencies": {
4848
"@launchdarkly/js-server-sdk-common": "2.14.0",
4949
"https-proxy-agent": "^5.0.1",
50-
"launchdarkly-eventsource": "2.0.3"
50+
"launchdarkly-eventsource": "2.1.0"
5151
},
5252
"devDependencies": {
5353
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { initMetadataFromHeaders } from '../../../src/internal/metadata';
2+
3+
it('handles passing undefined headers', () => {
4+
expect(initMetadataFromHeaders()).toBeUndefined();
5+
});
6+
7+
it('handles missing x-ld-envid header', () => {
8+
expect(initMetadataFromHeaders({})).toBeUndefined();
9+
});
10+
11+
it('retrieves environmentId from headers', () => {
12+
expect(initMetadataFromHeaders({ 'x-ld-envid': '12345' })).toEqual({ environmentId: '12345' });
13+
});
14+
15+
it('retrieves environmentId from mixed case header', () => {
16+
expect(initMetadataFromHeaders({ 'X-LD-EnvId': '12345' })).toEqual({ environmentId: '12345' });
17+
});

packages/shared/common/src/api/platform/EventSource.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ export type EventName = string;
44
export type EventListener = (event?: { data?: any }) => void;
55
export type ProcessStreamResponse = {
66
deserializeData: (data: string) => any;
7-
processJson: (json: any) => void;
7+
processJson: (json: any, initHeaders?: { [key: string]: string }) => void;
88
};
99

1010
export interface EventSource {
1111
onclose: (() => void) | undefined;
1212
onerror: ((err?: HttpErrorResponse) => void) | undefined;
13-
onopen: (() => void) | undefined;
13+
onopen: ((e: { headers?: { [key: string]: string } }) => void) | undefined;
1414
onretrying: ((e: { delayMillis: number }) => void) | undefined;
1515

1616
addEventListener(type: EventName, listener: EventListener): void;

packages/shared/common/src/internal/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from './diagnostics';
33
export * from './evaluation';
44
export * from './events';
55
export * from './fdv2';
6+
export * from './metadata';
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Metadata used to initialize an LDFeatureStore.
3+
*/
4+
export interface InitMetadata {
5+
environmentId: string;
6+
}
7+
8+
/**
9+
* Creates an InitMetadata object from initialization headers.
10+
*
11+
* @param initHeaders Initialization headers received when establishing
12+
* a streaming or polling connection to LD.
13+
* @returns InitMetadata object, or undefined if initHeaders is undefined
14+
* or missing the required header values.
15+
*/
16+
export function initMetadataFromHeaders(initHeaders?: {
17+
[key: string]: string;
18+
}): InitMetadata | undefined {
19+
if (initHeaders) {
20+
const envIdKey = Object.keys(initHeaders).find((key) => key.toLowerCase() === 'x-ld-envid');
21+
if (envIdKey) {
22+
return { environmentId: initHeaders[envIdKey] };
23+
}
24+
}
25+
return undefined;
26+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { InitMetadata, initMetadataFromHeaders } from './InitMetadata';
2+
3+
export { InitMetadata, initMetadataFromHeaders };

packages/shared/sdk-server/__tests__/data_sources/DataSourceUpdates.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,29 @@
11
import { AsyncQueue } from 'launchdarkly-js-test-helpers';
22

3+
import { internal } from '@launchdarkly/js-sdk-common';
4+
35
import { LDFeatureStore } from '../../src/api/subsystems';
46
import promisify from '../../src/async/promisify';
57
import DataSourceUpdates from '../../src/data_sources/DataSourceUpdates';
68
import InMemoryFeatureStore from '../../src/store/InMemoryFeatureStore';
79
import VersionedDataKinds from '../../src/store/VersionedDataKinds';
810

11+
type InitMetadata = internal.InitMetadata;
12+
13+
it('passes initialization metadata to underlying feature store', () => {
14+
const metadata: InitMetadata = { environmentId: '12345' };
15+
const store = new InMemoryFeatureStore();
16+
store.init = jest.fn();
17+
const updates = new DataSourceUpdates(
18+
store,
19+
() => false,
20+
() => {},
21+
);
22+
updates.init({}, () => {}, metadata);
23+
expect(store.init).toHaveBeenCalledTimes(1);
24+
expect(store.init).toHaveBeenNthCalledWith(1, expect.any(Object), expect.any(Function), metadata);
25+
});
26+
927
describe.each([true, false])(
1028
'given a DataSourceUpdates with in memory store and change listeners: %s',
1129
(listen) => {

packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,18 @@ describe('given an event processor', () => {
7676
expect(flags).toEqual(allData.flags);
7777
expect(segments).toEqual(allData.segments);
7878
});
79+
80+
it('initializes the feature store with metadata', () => {
81+
const initHeaders = {
82+
'x-ld-envid': '12345',
83+
};
84+
requestor.requestAllData = jest.fn((cb) => cb(undefined, jsonData, initHeaders));
85+
86+
processor.start();
87+
const metadata = storeFacade.getInitMetadata?.();
88+
89+
expect(metadata).toEqual({ environmentId: '12345' });
90+
});
7991
});
8092

8193
describe('given a polling processor with a short poll duration', () => {

packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ describe('given a requestor', () => {
4949
throw new Error('Function not implemented.');
5050
},
5151
entries(): Iterable<[string, string]> {
52-
throw new Error('Function not implemented.');
52+
return testHeaders ? Object.entries(testHeaders) : [];
5353
},
5454
has(_name: string): boolean {
5555
throw new Error('Function not implemented.');
@@ -115,7 +115,9 @@ describe('given a requestor', () => {
115115
});
116116

117117
it('stores and sends etags', async () => {
118-
testHeaders.etag = 'abc123';
118+
testHeaders = {
119+
etag: 'abc123',
120+
};
119121
testResponse = 'a response';
120122
const res1 = await promisify<{ err: any; body: any }>((cb) => {
121123
requestor.requestAllData((err, body) => cb({ err, body }));
@@ -134,4 +136,17 @@ describe('given a requestor', () => {
134136
expect(req1.options.headers?.['if-none-match']).toBe(undefined);
135137
expect(req2.options.headers?.['if-none-match']).toBe((testHeaders.etag = 'abc123'));
136138
});
139+
140+
it('passes response headers to callback', async () => {
141+
testHeaders = {
142+
header1: 'value1',
143+
header2: 'value2',
144+
header3: 'value3',
145+
};
146+
const res = await promisify<{ err: any; body: any; headers: any }>((cb) => {
147+
requestor.requestAllData((err, body, headers) => cb({ err, body, headers }));
148+
});
149+
150+
expect(res.headers).toEqual(testHeaders);
151+
});
137152
});

packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ describe('given a stream processor with mock event source', () => {
138138
});
139139

140140
it('uses expected uri and eventSource init args', () => {
141-
expect(basicPlatform.requests.createEventSource).toBeCalledWith(
141+
expect(basicPlatform.requests.createEventSource).toHaveBeenCalledWith(
142142
`${serviceEndpoints.streaming}/all`,
143143
{
144144
errorFilter: expect.any(Function),
@@ -200,32 +200,44 @@ describe('given a stream processor with mock event source', () => {
200200
const patchHandler = mockEventSource.addEventListener.mock.calls[1][1];
201201
patchHandler(event);
202202

203-
expect(mockListener.deserializeData).toBeCalledTimes(2);
204-
expect(mockListener.processJson).toBeCalledTimes(2);
203+
expect(mockListener.deserializeData).toHaveBeenCalledTimes(2);
204+
expect(mockListener.processJson).toHaveBeenCalledTimes(2);
205+
});
206+
207+
it('passes initialization headers to listener', () => {
208+
const headers = {
209+
header1: 'value1',
210+
header2: 'value2',
211+
header3: 'value3',
212+
};
213+
mockEventSource.onopen({ type: 'open', headers });
214+
simulatePutEvent();
215+
expect(mockListener.processJson).toHaveBeenCalledTimes(1);
216+
expect(mockListener.processJson).toHaveBeenNthCalledWith(1, expect.any(Object), headers);
205217
});
206218

207219
it('passes error to callback if json data is malformed', async () => {
208220
(mockListener.deserializeData as jest.Mock).mockReturnValue(false);
209221
simulatePutEvent();
210222

211-
expect(logger.error).toBeCalledWith(expect.stringMatching(/invalid data in "put"/));
212-
expect(logger.debug).toBeCalledWith(expect.stringMatching(/invalid json/i));
223+
expect(logger.error).toHaveBeenCalledWith(expect.stringMatching(/invalid data in "put"/));
224+
expect(logger.debug).toHaveBeenCalledWith(expect.stringMatching(/invalid json/i));
213225
expect(mockErrorHandler.mock.lastCall[0].message).toMatch(/malformed json/i);
214226
});
215227

216228
it('calls error handler if event.data prop is missing', async () => {
217229
simulatePutEvent({ flags: {} });
218230

219-
expect(mockListener.deserializeData).not.toBeCalled();
220-
expect(mockListener.processJson).not.toBeCalled();
231+
expect(mockListener.deserializeData).not.toHaveBeenCalled();
232+
expect(mockListener.processJson).not.toHaveBeenCalled();
221233
expect(mockErrorHandler.mock.lastCall[0].message).toMatch(/unexpected payload/i);
222234
});
223235

224236
it('closes and stops', async () => {
225237
streamingProcessor.close();
226238

227-
expect(streamingProcessor.stop).toBeCalled();
228-
expect(mockEventSource.close).toBeCalled();
239+
expect(streamingProcessor.stop).toHaveBeenCalled();
240+
expect(mockEventSource.close).toHaveBeenCalled();
229241
// @ts-ignore
230242
expect(streamingProcessor.eventSource).toBeUndefined();
231243
});
@@ -249,8 +261,8 @@ describe('given a stream processor with mock event source', () => {
249261
const willRetry = simulateError(testError);
250262

251263
expect(willRetry).toBeTruthy();
252-
expect(mockErrorHandler).not.toBeCalled();
253-
expect(logger.warn).toBeCalledWith(
264+
expect(mockErrorHandler).not.toHaveBeenCalled();
265+
expect(logger.warn).toHaveBeenCalledWith(
254266
expect.stringMatching(new RegExp(`${status}.*will retry`)),
255267
);
256268

@@ -270,10 +282,10 @@ describe('given a stream processor with mock event source', () => {
270282
const willRetry = simulateError(testError);
271283

272284
expect(willRetry).toBeFalsy();
273-
expect(mockErrorHandler).toBeCalledWith(
285+
expect(mockErrorHandler).toHaveBeenCalledWith(
274286
new LDStreamingError(DataSourceErrorKind.Unknown, testError.message, testError.status),
275287
);
276-
expect(logger.error).toBeCalledWith(
288+
expect(logger.error).toHaveBeenCalledWith(
277289
expect.stringMatching(new RegExp(`${status}.*permanently`)),
278290
);
279291

0 commit comments

Comments
 (0)