diff --git a/packages/sdk/server-node/package.json b/packages/sdk/server-node/package.json index 96a90bb69f..ce6c166332 100644 --- a/packages/sdk/server-node/package.json +++ b/packages/sdk/server-node/package.json @@ -47,7 +47,7 @@ "dependencies": { "@launchdarkly/js-server-sdk-common": "2.14.0", "https-proxy-agent": "^5.0.1", - "launchdarkly-eventsource": "2.0.3" + "launchdarkly-eventsource": "2.1.0" }, "devDependencies": { "@trivago/prettier-plugin-sort-imports": "^4.1.1", diff --git a/packages/shared/common/__tests__/internal/metadata/InitMetadata.test.ts b/packages/shared/common/__tests__/internal/metadata/InitMetadata.test.ts new file mode 100644 index 0000000000..5d6d132ba4 --- /dev/null +++ b/packages/shared/common/__tests__/internal/metadata/InitMetadata.test.ts @@ -0,0 +1,17 @@ +import { initMetadataFromHeaders } from '../../../src/internal/metadata'; + +it('handles passing undefined headers', () => { + expect(initMetadataFromHeaders()).toBeUndefined(); +}); + +it('handles missing x-ld-envid header', () => { + expect(initMetadataFromHeaders({})).toBeUndefined(); +}); + +it('retrieves environmentId from headers', () => { + expect(initMetadataFromHeaders({ 'x-ld-envid': '12345' })).toEqual({ environmentId: '12345' }); +}); + +it('retrieves environmentId from mixed case header', () => { + expect(initMetadataFromHeaders({ 'X-LD-EnvId': '12345' })).toEqual({ environmentId: '12345' }); +}); diff --git a/packages/shared/common/src/api/platform/EventSource.ts b/packages/shared/common/src/api/platform/EventSource.ts index f44fc830b2..3c0d920700 100644 --- a/packages/shared/common/src/api/platform/EventSource.ts +++ b/packages/shared/common/src/api/platform/EventSource.ts @@ -4,13 +4,13 @@ export type EventName = string; export type EventListener = (event?: { data?: any }) => void; export type ProcessStreamResponse = { deserializeData: (data: string) => any; - processJson: (json: any) => void; + processJson: (json: any, initHeaders?: { [key: string]: string }) => void; }; export interface EventSource { onclose: (() => void) | undefined; onerror: ((err?: HttpErrorResponse) => void) | undefined; - onopen: (() => void) | undefined; + onopen: ((e: { headers?: { [key: string]: string } }) => void) | undefined; onretrying: ((e: { delayMillis: number }) => void) | undefined; addEventListener(type: EventName, listener: EventListener): void; diff --git a/packages/shared/common/src/internal/index.ts b/packages/shared/common/src/internal/index.ts index 282da8f91f..ae6f5cb844 100644 --- a/packages/shared/common/src/internal/index.ts +++ b/packages/shared/common/src/internal/index.ts @@ -3,3 +3,4 @@ export * from './diagnostics'; export * from './evaluation'; export * from './events'; export * from './fdv2'; +export * from './metadata'; diff --git a/packages/shared/common/src/internal/metadata/InitMetadata.ts b/packages/shared/common/src/internal/metadata/InitMetadata.ts new file mode 100644 index 0000000000..db67fa3aef --- /dev/null +++ b/packages/shared/common/src/internal/metadata/InitMetadata.ts @@ -0,0 +1,26 @@ +/** + * Metadata used to initialize an LDFeatureStore. + */ +export interface InitMetadata { + environmentId: string; +} + +/** + * Creates an InitMetadata object from initialization headers. + * + * @param initHeaders Initialization headers received when establishing + * a streaming or polling connection to LD. + * @returns InitMetadata object, or undefined if initHeaders is undefined + * or missing the required header values. + */ +export function initMetadataFromHeaders(initHeaders?: { + [key: string]: string; +}): InitMetadata | undefined { + if (initHeaders) { + const envIdKey = Object.keys(initHeaders).find((key) => key.toLowerCase() === 'x-ld-envid'); + if (envIdKey) { + return { environmentId: initHeaders[envIdKey] }; + } + } + return undefined; +} diff --git a/packages/shared/common/src/internal/metadata/index.ts b/packages/shared/common/src/internal/metadata/index.ts new file mode 100644 index 0000000000..7e96b4a998 --- /dev/null +++ b/packages/shared/common/src/internal/metadata/index.ts @@ -0,0 +1,3 @@ +import { InitMetadata, initMetadataFromHeaders } from './InitMetadata'; + +export { InitMetadata, initMetadataFromHeaders }; diff --git a/packages/shared/sdk-server/__tests__/data_sources/DataSourceUpdates.test.ts b/packages/shared/sdk-server/__tests__/data_sources/DataSourceUpdates.test.ts index ff474c5f77..401589febd 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/DataSourceUpdates.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/DataSourceUpdates.test.ts @@ -1,11 +1,29 @@ import { AsyncQueue } from 'launchdarkly-js-test-helpers'; +import { internal } from '@launchdarkly/js-sdk-common'; + import { LDFeatureStore } from '../../src/api/subsystems'; import promisify from '../../src/async/promisify'; import DataSourceUpdates from '../../src/data_sources/DataSourceUpdates'; import InMemoryFeatureStore from '../../src/store/InMemoryFeatureStore'; import VersionedDataKinds from '../../src/store/VersionedDataKinds'; +type InitMetadata = internal.InitMetadata; + +it('passes initialization metadata to underlying feature store', () => { + const metadata: InitMetadata = { environmentId: '12345' }; + const store = new InMemoryFeatureStore(); + store.init = jest.fn(); + const updates = new DataSourceUpdates( + store, + () => false, + () => {}, + ); + updates.init({}, () => {}, metadata); + expect(store.init).toHaveBeenCalledTimes(1); + expect(store.init).toHaveBeenNthCalledWith(1, expect.any(Object), expect.any(Function), metadata); +}); + describe.each([true, false])( 'given a DataSourceUpdates with in memory store and change listeners: %s', (listen) => { diff --git a/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts b/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts index 05ae9ff282..1e43688301 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts @@ -76,6 +76,18 @@ describe('given an event processor', () => { expect(flags).toEqual(allData.flags); expect(segments).toEqual(allData.segments); }); + + it('initializes the feature store with metadata', () => { + const initHeaders = { + 'x-ld-envid': '12345', + }; + requestor.requestAllData = jest.fn((cb) => cb(undefined, jsonData, initHeaders)); + + processor.start(); + const metadata = storeFacade.getInitMetadata?.(); + + expect(metadata).toEqual({ environmentId: '12345' }); + }); }); describe('given a polling processor with a short poll duration', () => { diff --git a/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts b/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts index 3f3d8537a2..54b68f6312 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts @@ -49,7 +49,7 @@ describe('given a requestor', () => { throw new Error('Function not implemented.'); }, entries(): Iterable<[string, string]> { - throw new Error('Function not implemented.'); + return testHeaders ? Object.entries(testHeaders) : []; }, has(_name: string): boolean { throw new Error('Function not implemented.'); @@ -115,7 +115,9 @@ describe('given a requestor', () => { }); it('stores and sends etags', async () => { - testHeaders.etag = 'abc123'; + testHeaders = { + etag: 'abc123', + }; testResponse = 'a response'; const res1 = await promisify<{ err: any; body: any }>((cb) => { requestor.requestAllData((err, body) => cb({ err, body })); @@ -134,4 +136,17 @@ describe('given a requestor', () => { expect(req1.options.headers?.['if-none-match']).toBe(undefined); expect(req2.options.headers?.['if-none-match']).toBe((testHeaders.etag = 'abc123')); }); + + it('passes response headers to callback', async () => { + testHeaders = { + header1: 'value1', + header2: 'value2', + header3: 'value3', + }; + const res = await promisify<{ err: any; body: any; headers: any }>((cb) => { + requestor.requestAllData((err, body, headers) => cb({ err, body, headers })); + }); + + expect(res.headers).toEqual(testHeaders); + }); }); diff --git a/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts b/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts index f2b7b21aad..a91a90ffd2 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts @@ -138,7 +138,7 @@ describe('given a stream processor with mock event source', () => { }); it('uses expected uri and eventSource init args', () => { - expect(basicPlatform.requests.createEventSource).toBeCalledWith( + expect(basicPlatform.requests.createEventSource).toHaveBeenCalledWith( `${serviceEndpoints.streaming}/all`, { errorFilter: expect.any(Function), @@ -200,32 +200,44 @@ describe('given a stream processor with mock event source', () => { const patchHandler = mockEventSource.addEventListener.mock.calls[1][1]; patchHandler(event); - expect(mockListener.deserializeData).toBeCalledTimes(2); - expect(mockListener.processJson).toBeCalledTimes(2); + expect(mockListener.deserializeData).toHaveBeenCalledTimes(2); + expect(mockListener.processJson).toHaveBeenCalledTimes(2); + }); + + it('passes initialization headers to listener', () => { + const headers = { + header1: 'value1', + header2: 'value2', + header3: 'value3', + }; + mockEventSource.onopen({ type: 'open', headers }); + simulatePutEvent(); + expect(mockListener.processJson).toHaveBeenCalledTimes(1); + expect(mockListener.processJson).toHaveBeenNthCalledWith(1, expect.any(Object), headers); }); it('passes error to callback if json data is malformed', async () => { (mockListener.deserializeData as jest.Mock).mockReturnValue(false); simulatePutEvent(); - expect(logger.error).toBeCalledWith(expect.stringMatching(/invalid data in "put"/)); - expect(logger.debug).toBeCalledWith(expect.stringMatching(/invalid json/i)); + expect(logger.error).toHaveBeenCalledWith(expect.stringMatching(/invalid data in "put"/)); + expect(logger.debug).toHaveBeenCalledWith(expect.stringMatching(/invalid json/i)); expect(mockErrorHandler.mock.lastCall[0].message).toMatch(/malformed json/i); }); it('calls error handler if event.data prop is missing', async () => { simulatePutEvent({ flags: {} }); - expect(mockListener.deserializeData).not.toBeCalled(); - expect(mockListener.processJson).not.toBeCalled(); + expect(mockListener.deserializeData).not.toHaveBeenCalled(); + expect(mockListener.processJson).not.toHaveBeenCalled(); expect(mockErrorHandler.mock.lastCall[0].message).toMatch(/unexpected payload/i); }); it('closes and stops', async () => { streamingProcessor.close(); - expect(streamingProcessor.stop).toBeCalled(); - expect(mockEventSource.close).toBeCalled(); + expect(streamingProcessor.stop).toHaveBeenCalled(); + expect(mockEventSource.close).toHaveBeenCalled(); // @ts-ignore expect(streamingProcessor.eventSource).toBeUndefined(); }); @@ -249,8 +261,8 @@ describe('given a stream processor with mock event source', () => { const willRetry = simulateError(testError); expect(willRetry).toBeTruthy(); - expect(mockErrorHandler).not.toBeCalled(); - expect(logger.warn).toBeCalledWith( + expect(mockErrorHandler).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( expect.stringMatching(new RegExp(`${status}.*will retry`)), ); @@ -270,10 +282,10 @@ describe('given a stream processor with mock event source', () => { const willRetry = simulateError(testError); expect(willRetry).toBeFalsy(); - expect(mockErrorHandler).toBeCalledWith( + expect(mockErrorHandler).toHaveBeenCalledWith( new LDStreamingError(DataSourceErrorKind.Unknown, testError.message, testError.status), ); - expect(logger.error).toBeCalledWith( + expect(logger.error).toHaveBeenCalledWith( expect.stringMatching(new RegExp(`${status}.*permanently`)), ); diff --git a/packages/shared/sdk-server/__tests__/data_sources/createStreamListeners.test.ts b/packages/shared/sdk-server/__tests__/data_sources/createStreamListeners.test.ts index 3237b9417c..782ce51837 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/createStreamListeners.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/createStreamListeners.test.ts @@ -94,13 +94,36 @@ describe('createStreamListeners', () => { processJson(allData); - expect(logger.debug).toBeCalledWith(expect.stringMatching(/initializing/i)); - expect(dataSourceUpdates.init).toBeCalledWith( + expect(logger.debug).toHaveBeenCalledWith(expect.stringMatching(/initializing/i)); + expect(dataSourceUpdates.init).toHaveBeenCalledWith( { features: flags, segments, }, onPutCompleteHandler, + undefined, + ); + }); + + test('data source init is called with initialization metadata', async () => { + const listeners = createStreamListeners(dataSourceUpdates, logger, onCompleteHandlers); + const { processJson } = listeners.get('put')!; + const { + data: { flags, segments }, + } = allData; + const initHeaders = { + 'x-ld-envid': '12345', + }; + processJson(allData, initHeaders); + + expect(logger.debug).toHaveBeenCalledWith(expect.stringMatching(/initializing/i)); + expect(dataSourceUpdates.init).toHaveBeenCalledWith( + { + features: flags, + segments, + }, + onPutCompleteHandler, + { environmentId: '12345' }, ); }); }); @@ -121,8 +144,8 @@ describe('createStreamListeners', () => { processJson(patchData); - expect(logger.debug).toBeCalledWith(expect.stringMatching(/updating/i)); - expect(dataSourceUpdates.upsert).toBeCalledWith(kind, data, onPatchCompleteHandler); + expect(logger.debug).toHaveBeenCalledWith(expect.stringMatching(/updating/i)); + expect(dataSourceUpdates.upsert).toHaveBeenCalledWith(kind, data, onPatchCompleteHandler); }); test('data source upsert not called missing kind', async () => { @@ -132,7 +155,7 @@ describe('createStreamListeners', () => { processJson(missingKind); - expect(dataSourceUpdates.upsert).not.toBeCalled(); + expect(dataSourceUpdates.upsert).not.toHaveBeenCalled(); }); test('data source upsert not called wrong namespace path', async () => { @@ -142,7 +165,7 @@ describe('createStreamListeners', () => { processJson(wrongKey); - expect(dataSourceUpdates.upsert).not.toBeCalled(); + expect(dataSourceUpdates.upsert).not.toHaveBeenCalled(); }); }); @@ -162,8 +185,8 @@ describe('createStreamListeners', () => { processJson(deleteData); - expect(logger.debug).toBeCalledWith(expect.stringMatching(/deleting/i)); - expect(dataSourceUpdates.upsert).toBeCalledWith( + expect(logger.debug).toHaveBeenCalledWith(expect.stringMatching(/deleting/i)); + expect(dataSourceUpdates.upsert).toHaveBeenCalledWith( kind, { key: 'flagkey', version, deleted: true }, onDeleteCompleteHandler, @@ -177,7 +200,7 @@ describe('createStreamListeners', () => { processJson(missingKind); - expect(dataSourceUpdates.upsert).not.toBeCalled(); + expect(dataSourceUpdates.upsert).not.toHaveBeenCalled(); }); test('data source upsert not called wrong namespace path', async () => { @@ -187,7 +210,7 @@ describe('createStreamListeners', () => { processJson(wrongKey); - expect(dataSourceUpdates.upsert).not.toBeCalled(); + expect(dataSourceUpdates.upsert).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/shared/sdk-server/__tests__/hooks/HookRunner.test.ts b/packages/shared/sdk-server/__tests__/hooks/HookRunner.test.ts index b72a184f87..cd97d3fe38 100644 --- a/packages/shared/sdk-server/__tests__/hooks/HookRunner.test.ts +++ b/packages/shared/sdk-server/__tests__/hooks/HookRunner.test.ts @@ -36,6 +36,7 @@ describe('given a HookRunner', () => { reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' }, variationIndex: null, }), + '12345', ); testHook.verifyAfter( @@ -44,6 +45,7 @@ describe('given a HookRunner', () => { context: { ...defaultUser }, defaultValue: false, method: 'LDClient.variation', + environmentId: '12345', }, { added: 'added data' }, { @@ -187,6 +189,7 @@ it('can add a hook after initialization', async () => { reason: { kind: 'FALLTHROUGH' }, variationIndex: 0, }), + '12345', ); testHook.verifyBefore( { @@ -194,6 +197,7 @@ it('can add a hook after initialization', async () => { context: { ...defaultUser }, defaultValue: false, method: 'LDClient.variation', + environmentId: '12345', }, {}, ); @@ -203,6 +207,7 @@ it('can add a hook after initialization', async () => { context: { ...defaultUser }, defaultValue: false, method: 'LDClient.variation', + environmentId: '12345', }, {}, { diff --git a/packages/shared/sdk-server/__tests__/store/InMemoryFeatureStore.test.ts b/packages/shared/sdk-server/__tests__/store/InMemoryFeatureStore.test.ts index 0f77700a2f..114db7e849 100644 --- a/packages/shared/sdk-server/__tests__/store/InMemoryFeatureStore.test.ts +++ b/packages/shared/sdk-server/__tests__/store/InMemoryFeatureStore.test.ts @@ -147,4 +147,21 @@ describe('given an initialized feature store', () => { const feature = await featureStore.get({ namespace: 'potato' }, newPotato.key); expect(feature).toEqual(newPotato); }); + + it('returns undefined initMetadata', () => { + expect(featureStore.getInitMetadata?.()).toBeUndefined(); + }); +}); + +describe('given an initialized feature store with metadata', () => { + let featureStore: AsyncStoreFacade; + + beforeEach(async () => { + featureStore = new AsyncStoreFacade(new InMemoryFeatureStore()); + await featureStore.init({}, { environmentId: '12345' }); + }); + + it('returns correct metadata', () => { + expect(featureStore.getInitMetadata?.()).toEqual({ environmentId: '12345' }); + }); }); diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 152b7c066c..0b1d79901e 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -344,6 +344,7 @@ export default class LDClientImpl implements LDClient { }, ); }), + this._featureStore.getInitMetaData?.()?.environmentId, ) .then((detail) => { callback?.(null, detail.value); @@ -375,6 +376,7 @@ export default class LDClientImpl implements LDClient { }, ); }), + this._featureStore.getInitMetaData?.()?.environmentId, ); } @@ -409,6 +411,7 @@ export default class LDClientImpl implements LDClient { typeChecker, ); }), + this._featureStore.getInitMetaData?.()?.environmentId, ); } @@ -470,6 +473,7 @@ export default class LDClientImpl implements LDClient { }, ); }), + this._featureStore.getInitMetaData?.()?.environmentId, ) .then((detail) => detail.value); } @@ -541,6 +545,7 @@ export default class LDClientImpl implements LDClient { }, ); }), + this._featureStore.getInitMetaData?.()?.environmentId, ); } @@ -615,6 +620,7 @@ export default class LDClientImpl implements LDClient { defaultValue, MIGRATION_VARIATION_METHOD_NAME, () => this._migrationVariationInternal(key, context, defaultValue), + this._featureStore.getInitMetaData?.()?.environmentId, ); return res.migration; diff --git a/packages/shared/sdk-server/src/api/integrations/Hook.ts b/packages/shared/sdk-server/src/api/integrations/Hook.ts index 52e7639866..71d023a5ad 100644 --- a/packages/shared/sdk-server/src/api/integrations/Hook.ts +++ b/packages/shared/sdk-server/src/api/integrations/Hook.ts @@ -8,6 +8,7 @@ export interface EvaluationSeriesContext { readonly context: LDContext; readonly defaultValue: unknown; readonly method: string; + readonly environmentId?: string; } /** diff --git a/packages/shared/sdk-server/src/api/subsystems/LDDataSourceUpdates.ts b/packages/shared/sdk-server/src/api/subsystems/LDDataSourceUpdates.ts index 8b3badd386..4941d0c5b5 100644 --- a/packages/shared/sdk-server/src/api/subsystems/LDDataSourceUpdates.ts +++ b/packages/shared/sdk-server/src/api/subsystems/LDDataSourceUpdates.ts @@ -1,6 +1,10 @@ +import { internal } from '@launchdarkly/js-sdk-common'; + import { DataKind } from '../interfaces'; import { LDFeatureStoreDataStorage, LDKeyedFeatureStoreItem } from './LDFeatureStore'; +type InitMetadata = internal.InitMetadata; + /** * Interface that a data source implementation will use to push data into the SDK. * @@ -19,8 +23,11 @@ export interface LDDataSourceUpdates { * * @param callback * Will be called when the store has been initialized. + * + * @param initMetadata + * Optional metadata to initialize the data source with. */ - init(allData: LDFeatureStoreDataStorage, callback: () => void): void; + init(allData: LDFeatureStoreDataStorage, callback: () => void, initMetadata?: InitMetadata): void; /** * Updates or inserts an item in the specified collection. For updates, the object will only be diff --git a/packages/shared/sdk-server/src/api/subsystems/LDFeatureRequestor.ts b/packages/shared/sdk-server/src/api/subsystems/LDFeatureRequestor.ts index c287d6617a..43b3c261c2 100644 --- a/packages/shared/sdk-server/src/api/subsystems/LDFeatureRequestor.ts +++ b/packages/shared/sdk-server/src/api/subsystems/LDFeatureRequestor.ts @@ -6,5 +6,5 @@ * @ignore */ export interface LDFeatureRequestor { - requestAllData: (cb: (err: any, body: any) => void) => void; + requestAllData: (cb: (err: any, body: any, headers: any) => void) => void; } diff --git a/packages/shared/sdk-server/src/api/subsystems/LDFeatureStore.ts b/packages/shared/sdk-server/src/api/subsystems/LDFeatureStore.ts index 9bdfb94307..ef3f8cac2d 100644 --- a/packages/shared/sdk-server/src/api/subsystems/LDFeatureStore.ts +++ b/packages/shared/sdk-server/src/api/subsystems/LDFeatureStore.ts @@ -1,5 +1,9 @@ +import { internal } from '@launchdarkly/js-sdk-common'; + import { DataKind } from '../interfaces'; +type InitMetadata = internal.InitMetadata; + /** * Represents an item which can be stored in the feature store. */ @@ -92,8 +96,11 @@ export interface LDFeatureStore { * * @param callback * Will be called when the store has been initialized. + * + * @param initMetadata + * Optional metadata to initialize the feature store with. */ - init(allData: LDFeatureStoreDataStorage, callback: () => void): void; + init(allData: LDFeatureStoreDataStorage, callback: () => void, initMetadata?: InitMetadata): void; /** * Delete an entity from the store. @@ -158,4 +165,9 @@ export interface LDFeatureStore { * Get a description of the store. */ getDescription?(): string; + + /** + * Get the initialization metadata of the store. + */ + getInitMetaData?(): InitMetadata | undefined; } diff --git a/packages/shared/sdk-server/src/data_sources/DataSourceUpdates.ts b/packages/shared/sdk-server/src/data_sources/DataSourceUpdates.ts index ac6e3820dc..e1c190a531 100644 --- a/packages/shared/sdk-server/src/data_sources/DataSourceUpdates.ts +++ b/packages/shared/sdk-server/src/data_sources/DataSourceUpdates.ts @@ -1,3 +1,5 @@ +import { internal } from '@launchdarkly/js-sdk-common'; + import { DataKind } from '../api/interfaces'; import { LDDataSourceUpdates, @@ -13,6 +15,8 @@ import VersionedDataKinds from '../store/VersionedDataKinds'; import DependencyTracker from './DependencyTracker'; import NamespacedDataSet from './NamespacedDataSet'; +type InitMetadata = internal.InitMetadata; + /** * This type allows computing the clause dependencies of either a flag or a segment. */ @@ -66,46 +70,54 @@ export default class DataSourceUpdates implements LDDataSourceUpdates { private readonly _onChange: (key: string) => void, ) {} - init(allData: LDFeatureStoreDataStorage, callback: () => void): void { + init( + allData: LDFeatureStoreDataStorage, + callback: () => void, + initMetadata?: InitMetadata, + ): void { const checkForChanges = this._hasEventListeners(); const doInit = (oldData?: LDFeatureStoreDataStorage) => { - this._featureStore.init(allData, () => { - // Defer change events so they execute after the callback. - Promise.resolve().then(() => { - this._dependencyTracker.reset(); - - Object.entries(allData).forEach(([namespace, items]) => { - Object.keys(items || {}).forEach((key) => { - const item = items[key]; - this._dependencyTracker.updateDependenciesFrom( - namespace, - key, - computeDependencies(namespace, item), - ); - }); - }); + this._featureStore.init( + allData, + () => { + // Defer change events so they execute after the callback. + Promise.resolve().then(() => { + this._dependencyTracker.reset(); - if (checkForChanges) { - const updatedItems = new NamespacedDataSet(); - Object.keys(allData).forEach((namespace) => { - const oldDataForKind = oldData?.[namespace] || {}; - const newDataForKind = allData[namespace]; - const mergedData = { ...oldDataForKind, ...newDataForKind }; - Object.keys(mergedData).forEach((key) => { - this.addIfModified( + Object.entries(allData).forEach(([namespace, items]) => { + Object.keys(items || {}).forEach((key) => { + const item = items[key]; + this._dependencyTracker.updateDependenciesFrom( namespace, key, - oldDataForKind && oldDataForKind[key], - newDataForKind && newDataForKind[key], - updatedItems, + computeDependencies(namespace, item), ); }); }); - this.sendChangeEvents(updatedItems); - } - }); - callback?.(); - }); + + if (checkForChanges) { + const updatedItems = new NamespacedDataSet(); + Object.keys(allData).forEach((namespace) => { + const oldDataForKind = oldData?.[namespace] || {}; + const newDataForKind = allData[namespace]; + const mergedData = { ...oldDataForKind, ...newDataForKind }; + Object.keys(mergedData).forEach((key) => { + this.addIfModified( + namespace, + key, + oldDataForKind && oldDataForKind[key], + newDataForKind && newDataForKind[key], + updatedItems, + ); + }); + }); + this.sendChangeEvents(updatedItems); + } + }); + callback?.(); + }, + initMetadata, + ); }; if (checkForChanges) { diff --git a/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts b/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts index d376b45354..07ef5bf9f0 100644 --- a/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts +++ b/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts @@ -1,6 +1,7 @@ import { DataSourceErrorKind, httpErrorMessage, + internal, isHttpRecoverable, LDLogger, LDPollingError, @@ -16,6 +17,8 @@ import Requestor from './Requestor'; export type PollingErrorHandler = (err: LDPollingError) => void; +const { initMetadataFromHeaders } = internal; + /** * @internal */ @@ -57,7 +60,7 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { const startTime = Date.now(); this._logger?.debug('Polling LaunchDarkly for feature flag updates'); - this._requestor.requestAllData((err, body) => { + this._requestor.requestAllData((err, body, headers) => { const elapsed = Date.now() - startTime; const sleepFor = Math.max(this._pollInterval * 1000 - elapsed, 0); @@ -86,13 +89,17 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { [VersionedDataKinds.Features.namespace]: parsed.flags, [VersionedDataKinds.Segments.namespace]: parsed.segments, }; - this._featureStore.init(initData, () => { - this._initSuccessHandler(); - // Triggering the next poll after the init has completed. - this._timeoutHandle = setTimeout(() => { - this._poll(); - }, sleepFor); - }); + this._featureStore.init( + initData, + () => { + this._initSuccessHandler(); + // Triggering the next poll after the init has completed. + this._timeoutHandle = setTimeout(() => { + this._poll(); + }, sleepFor); + }, + initMetadataFromHeaders(headers), + ); // The poll will be triggered by the feature store initialization // completing. return; diff --git a/packages/shared/sdk-server/src/data_sources/Requestor.ts b/packages/shared/sdk-server/src/data_sources/Requestor.ts index 0d3567eae8..4f59bda9b7 100644 --- a/packages/shared/sdk-server/src/data_sources/Requestor.ts +++ b/packages/shared/sdk-server/src/data_sources/Requestor.ts @@ -70,7 +70,7 @@ export default class Requestor implements LDFeatureRequestor { return { res, body }; } - async requestAllData(cb: (err: any, body: any) => void) { + async requestAllData(cb: (err: any, body: any, headers: any) => void) { const options: Options = { method: 'GET', headers: this._headers, @@ -83,11 +83,15 @@ export default class Requestor implements LDFeatureRequestor { `Unexpected status code: ${res.status}`, res.status, ); - return cb(err, undefined); + return cb(err, undefined, undefined); } - return cb(undefined, res.status === 304 ? null : body); + return cb( + undefined, + res.status === 304 ? null : body, + Object.fromEntries(res.headers.entries()), + ); } catch (err) { - return cb(err, undefined); + return cb(err, undefined, undefined); } } } diff --git a/packages/shared/sdk-server/src/data_sources/StreamingProcessor.ts b/packages/shared/sdk-server/src/data_sources/StreamingProcessor.ts index e752f4863c..41cd409174 100644 --- a/packages/shared/sdk-server/src/data_sources/StreamingProcessor.ts +++ b/packages/shared/sdk-server/src/data_sources/StreamingProcessor.ts @@ -38,6 +38,7 @@ export default class StreamingProcessor implements subsystem.LDStreamProcessor { private _eventSource?: EventSource; private _requests: Requests; private _connectionAttemptStartTime?: number; + private _initHeaders?: { [key: string]: string }; constructor( clientContext: ClientContext, @@ -125,7 +126,8 @@ export default class StreamingProcessor implements subsystem.LDStreamProcessor { // The work is done by `errorFilter`. }; - eventSource.onopen = () => { + eventSource.onopen = (e) => { + this._initHeaders = e.headers; this._logger?.info('Opened LaunchDarkly stream connection'); }; @@ -146,7 +148,7 @@ export default class StreamingProcessor implements subsystem.LDStreamProcessor { reportJsonError(eventName, data, this._logger, this._errorHandler); return; } - processJson(dataJson); + processJson(dataJson, this._initHeaders); } else { this._errorHandler?.( new LDStreamingError( diff --git a/packages/shared/sdk-server/src/data_sources/createStreamListeners.ts b/packages/shared/sdk-server/src/data_sources/createStreamListeners.ts index 391e141914..6e453196cb 100644 --- a/packages/shared/sdk-server/src/data_sources/createStreamListeners.ts +++ b/packages/shared/sdk-server/src/data_sources/createStreamListeners.ts @@ -1,5 +1,6 @@ import { EventName, + internal, LDLogger, ProcessStreamResponse, VoidFunction, @@ -16,20 +17,24 @@ import { } from '../store/serialization'; import VersionedDataKinds from '../store/VersionedDataKinds'; +const { initMetadataFromHeaders } = internal; + export const createPutListener = ( dataSourceUpdates: LDDataSourceUpdates, logger?: LDLogger, onPutCompleteHandler: VoidFunction = () => {}, ) => ({ deserializeData: deserializeAll, - processJson: async ({ data: { flags, segments } }: AllData) => { + processJson: async ( + { data: { flags, segments } }: AllData, + initHeaders?: { [key: string]: string }, + ) => { const initData = { [VersionedDataKinds.Features.namespace]: flags, [VersionedDataKinds.Segments.namespace]: segments, }; - logger?.debug('Initializing all data'); - dataSourceUpdates.init(initData, onPutCompleteHandler); + dataSourceUpdates.init(initData, onPutCompleteHandler, initMetadataFromHeaders(initHeaders)); }, }); diff --git a/packages/shared/sdk-server/src/hooks/HookRunner.ts b/packages/shared/sdk-server/src/hooks/HookRunner.ts index c7c1e61f4f..315970f1b4 100644 --- a/packages/shared/sdk-server/src/hooks/HookRunner.ts +++ b/packages/shared/sdk-server/src/hooks/HookRunner.ts @@ -22,6 +22,7 @@ export default class HookRunner { defaultValue: unknown, methodName: string, method: () => Promise, + environmentId?: string, ): Promise { // This early return is here to avoid the extra async/await associated with // using withHooksDataWithDetail. @@ -38,6 +39,7 @@ export default class HookRunner { const detail = await method(); return { detail }; }, + environmentId, ).then(({ detail }) => detail); } @@ -51,12 +53,13 @@ export default class HookRunner { defaultValue: unknown, methodName: string, method: () => Promise<{ detail: LDEvaluationDetail; [index: string]: any }>, + environmentId?: string, ): Promise<{ detail: LDEvaluationDetail; [index: string]: any }> { if (this._hooks.length === 0) { return method(); } const { hooks, hookContext }: { hooks: Hook[]; hookContext: EvaluationSeriesContext } = - this._prepareHooks(key, context, defaultValue, methodName); + this._prepareHooks(key, context, defaultValue, methodName, environmentId); const hookData = this._executeBeforeEvaluation(hooks, hookContext); const result = await method(); this._executeAfterEvaluation(hooks, hookContext, hookData, result.detail); @@ -124,6 +127,7 @@ export default class HookRunner { context: LDContext, defaultValue: unknown, methodName: string, + environmentId?: string, ): { hooks: Hook[]; hookContext: EvaluationSeriesContext; @@ -137,6 +141,7 @@ export default class HookRunner { context, defaultValue, method: methodName, + environmentId, }; return { hooks, hookContext }; } diff --git a/packages/shared/sdk-server/src/store/AsyncStoreFacade.ts b/packages/shared/sdk-server/src/store/AsyncStoreFacade.ts index 5d24d81997..d49eddb81c 100644 --- a/packages/shared/sdk-server/src/store/AsyncStoreFacade.ts +++ b/packages/shared/sdk-server/src/store/AsyncStoreFacade.ts @@ -1,3 +1,5 @@ +import { internal } from '@launchdarkly/js-sdk-common'; + import { DataKind } from '../api/interfaces'; import { LDFeatureStore, @@ -8,6 +10,8 @@ import { } from '../api/subsystems'; import promisify from '../async/promisify'; +type InitMetadata = internal.InitMetadata; + /** * Provides an async interface to a feature store. * @@ -33,9 +37,9 @@ export default class AsyncStoreFacade { }); } - async init(allData: LDFeatureStoreDataStorage): Promise { + async init(allData: LDFeatureStoreDataStorage, initMetadata?: InitMetadata): Promise { return promisify((cb) => { - this._store.init(allData, cb); + this._store.init(allData, cb, initMetadata); }); } @@ -60,4 +64,8 @@ export default class AsyncStoreFacade { close(): void { this._store.close(); } + + getInitMetadata?(): InitMetadata | undefined { + return this._store.getInitMetaData?.(); + } } diff --git a/packages/shared/sdk-server/src/store/InMemoryFeatureStore.ts b/packages/shared/sdk-server/src/store/InMemoryFeatureStore.ts index 61814f2aac..30d9d6db7c 100644 --- a/packages/shared/sdk-server/src/store/InMemoryFeatureStore.ts +++ b/packages/shared/sdk-server/src/store/InMemoryFeatureStore.ts @@ -1,3 +1,5 @@ +import { internal } from '@launchdarkly/js-sdk-common'; + import { DataKind } from '../api/interfaces'; import { LDFeatureStore, @@ -7,11 +9,15 @@ import { LDKeyedFeatureStoreItem, } from '../api/subsystems'; +type InitMetadata = internal.InitMetadata; + export default class InMemoryFeatureStore implements LDFeatureStore { private _allData: LDFeatureStoreDataStorage = {}; private _initCalled = false; + private _initMetadata?: InitMetadata; + private _addItem(kind: DataKind, key: string, item: LDFeatureStoreItem) { let items = this._allData[kind.namespace]; if (!items) { @@ -52,9 +58,14 @@ export default class InMemoryFeatureStore implements LDFeatureStore { callback?.(result); } - init(allData: LDFeatureStoreDataStorage, callback: () => void): void { + init( + allData: LDFeatureStoreDataStorage, + callback: () => void, + initMetadata?: InitMetadata, + ): void { this._initCalled = true; this._allData = allData as LDFeatureStoreDataStorage; + this._initMetadata = initMetadata; callback?.(); } @@ -81,4 +92,8 @@ export default class InMemoryFeatureStore implements LDFeatureStore { getDescription(): string { return 'memory'; } + + getInitMetaData(): InitMetadata | undefined { + return this._initMetadata; + } } diff --git a/packages/telemetry/node-server-sdk-otel/__tests__/TracingHook.test.ts b/packages/telemetry/node-server-sdk-otel/__tests__/TracingHook.test.ts index edc110b1ec..de1cd1325c 100644 --- a/packages/telemetry/node-server-sdk-otel/__tests__/TracingHook.test.ts +++ b/packages/telemetry/node-server-sdk-otel/__tests__/TracingHook.test.ts @@ -26,15 +26,20 @@ it('validates configuration', async () => { messages.push(text); }, }), + // @ts-ignore + environmentId: 12345, }); - expect(messages.length).toEqual(2); + expect(messages.length).toEqual(3); expect(messages[0]).toEqual( 'error: [LaunchDarkly] Config option "includeVariant" should be of type boolean, got string, using default value', ); expect(messages[1]).toEqual( 'error: [LaunchDarkly] Config option "spans" should be of type boolean, got string, using default value', ); + expect(messages[2]).toEqual( + 'error: [LaunchDarkly] Config option "environmentId" should be of type string, got number, using default value', + ); }); it('instance can be created with default config', () => { @@ -72,6 +77,7 @@ describe('with a testing otel span collector', () => { expect(spanEvent.attributes!['feature_flag.provider_name']).toEqual('LaunchDarkly'); expect(spanEvent.attributes!['feature_flag.context.key']).toEqual('user-key'); expect(spanEvent.attributes!['feature_flag.variant']).toBeUndefined(); + expect(spanEvent.attributes!['feature_flag.set.id']).toBeUndefined(); }); it('can include variant in span events', async () => { @@ -135,4 +141,81 @@ describe('with a testing otel span collector', () => { const spanEvent = spans[0]!.events[0]!; expect(spanEvent.attributes!['feature_flag.context.key']).toEqual('org:org-key:user:bob'); }); + + it('can include environmentId from options', async () => { + const td = new integrations.TestData(); + const client = init('bad-key', { + sendEvents: false, + updateProcessor: td.getFactory(), + hooks: [new TracingHook({ environmentId: 'id-from-options' })], + }); + + const tracer = trace.getTracer('trace-hook-test-tracer'); + await tracer.startActiveSpan('test-span', { root: true }, async (span) => { + await client.boolVariation('test-bool', { kind: 'user', key: 'user-key' }, false); + span.end(); + }); + + const spans = spanExporter.getFinishedSpans(); + const spanEvent = spans[0]!.events[0]!; + expect(spanEvent.attributes!['feature_flag.set.id']).toEqual('id-from-options'); + }); + + it('can include environmentId from hook context', async () => { + const hook = new TracingHook(); + const td = new integrations.TestData(); + const client = init('bad-key', { + sendEvents: false, + updateProcessor: td.getFactory(), + hooks: [hook], + }); + + jest.spyOn(hook, 'afterEvaluation').mockImplementationOnce((hookContext, data, detail) => + // @ts-ignore + hook.afterEvaluation?.( + { ...hookContext, environmentId: 'id-from-hook-context' }, + data, + detail, + ), + ); + + const tracer = trace.getTracer('trace-hook-test-tracer'); + await tracer.startActiveSpan('test-span', { root: true }, async (span) => { + await client.boolVariation('test-bool', { kind: 'user', key: 'user-key' }, false); + span.end(); + }); + + const spans = spanExporter.getFinishedSpans(); + const spanEvent = spans[0]!.events[0]!; + expect(spanEvent.attributes!['feature_flag.set.id']).toEqual('id-from-hook-context'); + }); + + it('can override hook context environmentId with options', async () => { + const hook = new TracingHook({ environmentId: 'id-from-options' }); + const td = new integrations.TestData(); + const client = init('bad-key', { + sendEvents: false, + updateProcessor: td.getFactory(), + hooks: [hook], + }); + + jest.spyOn(hook, 'afterEvaluation').mockImplementationOnce((hookContext, data, detail) => + // @ts-ignore + hook.afterEvaluation?.( + { ...hookContext, environmentId: 'id-from-hook-context' }, + data, + detail, + ), + ); + + const tracer = trace.getTracer('trace-hook-test-tracer'); + await tracer.startActiveSpan('test-span', { root: true }, async (span) => { + await client.boolVariation('test-bool', { kind: 'user', key: 'user-key' }, false); + span.end(); + }); + + const spans = spanExporter.getFinishedSpans(); + const spanEvent = spans[0]!.events[0]!; + expect(spanEvent.attributes!['feature_flag.set.id']).toEqual('id-from-options'); + }); }); diff --git a/packages/telemetry/node-server-sdk-otel/src/TracingHook.ts b/packages/telemetry/node-server-sdk-otel/src/TracingHook.ts index 087464c5cb..35d5911a3f 100644 --- a/packages/telemetry/node-server-sdk-otel/src/TracingHook.ts +++ b/packages/telemetry/node-server-sdk-otel/src/TracingHook.ts @@ -17,6 +17,7 @@ const FEATURE_FLAG_KEY_ATTR = `${FEATURE_FLAG_SCOPE}.key`; const FEATURE_FLAG_PROVIDER_ATTR = `${FEATURE_FLAG_SCOPE}.provider_name`; const FEATURE_FLAG_CONTEXT_KEY_ATTR = `${FEATURE_FLAG_SCOPE}.context.key`; const FEATURE_FLAG_VARIANT_ATTR = `${FEATURE_FLAG_SCOPE}.variant`; +const FEATURE_FLAG_SET_ID = `${FEATURE_FLAG_SCOPE}.set.id`; const TRACING_HOOK_NAME = 'LaunchDarkly Tracing Hook'; @@ -49,12 +50,15 @@ export interface TracingHookOptions { * using `console`. */ logger?: LDLogger; + + environmentId?: string; } interface ValidatedHookOptions { spans: boolean; includeVariant: boolean; logger: LDLogger; + environmentId?: string; } type SpanTraceData = { @@ -65,6 +69,7 @@ const defaultOptions: ValidatedHookOptions = { spans: false, includeVariant: false, logger: basicLogger({ name: TRACING_HOOK_NAME }), + environmentId: undefined, }; function validateOptions(options?: TracingHookOptions): ValidatedHookOptions { @@ -94,6 +99,16 @@ function validateOptions(options?: TracingHookOptions): ValidatedHookOptions { } } + if (options?.environmentId !== undefined) { + if (TypeValidators.String.is(options.environmentId)) { + validatedOptions.environmentId = options.environmentId; + } else { + validatedOptions.logger.error( + OptionMessages.wrongOptionType('environmentId', 'string', typeof options?.environmentId), + ); + } + } + return validatedOptions; } @@ -163,6 +178,11 @@ export default class TracingHook implements integrations.Hook { [FEATURE_FLAG_PROVIDER_ATTR]: 'LaunchDarkly', [FEATURE_FLAG_CONTEXT_KEY_ATTR]: Context.fromLDContext(hookContext.context).canonicalKey, }; + if (this._options.environmentId) { + eventAttributes[FEATURE_FLAG_SET_ID] = this._options.environmentId; + } else if (hookContext.environmentId) { + eventAttributes[FEATURE_FLAG_SET_ID] = hookContext.environmentId; + } if (this._options.includeVariant) { eventAttributes[FEATURE_FLAG_VARIANT_ATTR] = JSON.stringify(detail.value); }