diff --git a/contract-tests/index.js b/contract-tests/index.js index 3b507ffca..e3716a367 100644 --- a/contract-tests/index.js +++ b/contract-tests/index.js @@ -27,6 +27,8 @@ app.get('/', (req, res) => { 'all-flags-with-reasons', 'tags', 'big-segments', + 'filtering', + 'filtering-strict', 'user-type', 'migrations', 'event-sampling', @@ -35,7 +37,7 @@ app.get('/', (req, res) => { 'inline-context', 'anonymous-redaction', 'evaluation-hooks', - 'wrapper' + 'wrapper', ], }); }); diff --git a/contract-tests/sdkClientEntity.js b/contract-tests/sdkClientEntity.js index 930afdeeb..a40d7703e 100644 --- a/contract-tests/sdkClientEntity.js +++ b/contract-tests/sdkClientEntity.js @@ -26,11 +26,17 @@ export function makeSdkConfig(options, tag) { if (options.streaming) { cf.streamUri = options.streaming.baseUri; cf.streamInitialReconnectDelay = maybeTime(options.streaming.initialRetryDelayMs); + if (options.streaming.filter) { + cf.payloadFilterKey = options.streaming.filter; + } } if (options.polling) { cf.stream = false; cf.baseUri = options.polling.baseUri; cf.pollInterface = options.polling.pollIntervalMs / 1000; + if (options.polling.filter) { + cf.payloadFilterKey = options.polling.filter; + } } if (options.events) { cf.allAttributesPrivate = options.events.allAttributesPrivate; diff --git a/packages/shared/common/__tests__/options/ServiceEndpoints.test.ts b/packages/shared/common/__tests__/options/ServiceEndpoints.test.ts index ffa7bf5bc..c08d09f8d 100644 --- a/packages/shared/common/__tests__/options/ServiceEndpoints.test.ts +++ b/packages/shared/common/__tests__/options/ServiceEndpoints.test.ts @@ -1,4 +1,8 @@ -import ServiceEndpoints from '../../src/options/ServiceEndpoints'; +import ServiceEndpoints, { + getEventsUri, + getPollingUri, + getStreamingUri, +} from '../../src/options/ServiceEndpoints'; describe.each([ [ @@ -33,3 +37,26 @@ describe.each([ expect(endpoints.events).toEqual(expected.eventsUri); }); }); + +it('applies payload filter to polling and streaming endpoints', () => { + const endpoints = new ServiceEndpoints( + 'https://stream.launchdarkly.com', + 'https://sdk.launchdarkly.com', + 'https://events.launchdarkly.com', + '/bulk', + '/diagnostic', + true, + 'filterKey', + ); + + expect(getStreamingUri(endpoints, '/all', [])).toEqual( + 'https://stream.launchdarkly.com/all?filter=filterKey', + ); + expect(getPollingUri(endpoints, '/sdk/latest-all', [])).toEqual( + 'https://sdk.launchdarkly.com/sdk/latest-all?filter=filterKey', + ); + expect( + getPollingUri(endpoints, '/sdk/latest-all', [{ key: 'withReasons', value: 'true' }]), + ).toEqual('https://sdk.launchdarkly.com/sdk/latest-all?withReasons=true&filter=filterKey'); + expect(getEventsUri(endpoints, '/bulk', [])).toEqual('https://events.launchdarkly.com/bulk'); +}); diff --git a/packages/shared/common/src/internal/events/EventSender.ts b/packages/shared/common/src/internal/events/EventSender.ts index 30b161aba..afee52df1 100644 --- a/packages/shared/common/src/internal/events/EventSender.ts +++ b/packages/shared/common/src/internal/events/EventSender.ts @@ -10,7 +10,7 @@ import { isHttpRecoverable, LDUnexpectedResponseError, } from '../../errors'; -import { ClientContext } from '../../options'; +import { ClientContext, getEventsUri } from '../../options'; import { defaultHeaders, httpErrorMessage, sleep } from '../../utils'; export default class EventSender implements LDEventSender { @@ -26,19 +26,18 @@ export default class EventSender implements LDEventSender { const { basicConfiguration, platform } = clientContext; const { sdkKey, - serviceEndpoints: { - events, - analyticsEventPath, - diagnosticEventPath, - includeAuthorizationHeader, - }, + serviceEndpoints: { analyticsEventPath, diagnosticEventPath, includeAuthorizationHeader }, tags, } = basicConfiguration; const { crypto, info, requests } = platform; this.defaultHeaders = defaultHeaders(sdkKey, info, tags, includeAuthorizationHeader); - this.eventsUri = `${events}${analyticsEventPath}`; - this.diagnosticEventsUri = `${events}${diagnosticEventPath}`; + this.eventsUri = getEventsUri(basicConfiguration.serviceEndpoints, analyticsEventPath, []); + this.diagnosticEventsUri = getEventsUri( + basicConfiguration.serviceEndpoints, + diagnosticEventPath, + [], + ); this.requests = requests; this.crypto = crypto; } diff --git a/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts b/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts index 0fd17d9f4..e91b93e16 100644 --- a/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts +++ b/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts @@ -107,6 +107,7 @@ describe('given a stream processor with mock event source', () => { platform: basicPlatform, }, '/all', + [], listeners, diagnosticsManager, mockErrorHandler, @@ -142,6 +143,7 @@ describe('given a stream processor with mock event source', () => { platform: basicPlatform, }, '/all', + [], listeners, diagnosticsManager, mockErrorHandler, diff --git a/packages/shared/common/src/internal/stream/StreamingProcessor.ts b/packages/shared/common/src/internal/stream/StreamingProcessor.ts index db67c4647..d9ccfaab4 100644 --- a/packages/shared/common/src/internal/stream/StreamingProcessor.ts +++ b/packages/shared/common/src/internal/stream/StreamingProcessor.ts @@ -9,6 +9,7 @@ import { import { LDStreamProcessor } from '../../api/subsystem'; import { LDStreamingError } from '../../errors'; import { ClientContext } from '../../options'; +import { getStreamingUri } from '../../options/ServiceEndpoints'; import { defaultHeaders, httpErrorMessage, shouldRetry } from '../../utils'; import { DiagnosticsManager } from '../diagnostics'; import { StreamingErrorHandler } from './types'; @@ -37,6 +38,7 @@ class StreamingProcessor implements LDStreamProcessor { sdkKey: string, clientContext: ClientContext, streamUriPath: string, + parameters: { key: string; value: string }[], private readonly listeners: Map, private readonly diagnosticsManager?: DiagnosticsManager, private readonly errorHandler?: StreamingErrorHandler, @@ -49,7 +51,11 @@ class StreamingProcessor implements LDStreamProcessor { this.headers = defaultHeaders(sdkKey, info, tags); this.logger = logger; this.requests = requests; - this.streamUri = `${basicConfiguration.serviceEndpoints.streaming}${streamUriPath}`; + this.streamUri = getStreamingUri( + basicConfiguration.serviceEndpoints, + streamUriPath, + parameters, + ); } private logConnectionStarted() { diff --git a/packages/shared/common/src/options/ServiceEndpoints.ts b/packages/shared/common/src/options/ServiceEndpoints.ts index 4debefa46..d0781b0a9 100644 --- a/packages/shared/common/src/options/ServiceEndpoints.ts +++ b/packages/shared/common/src/options/ServiceEndpoints.ts @@ -2,6 +2,10 @@ function canonicalizeUri(uri: string): string { return uri.replace(/\/+$/, ''); } +function canonicalizePath(path: string): string { + return path.replace(/^\/+/, '').replace(/\?$/, ''); +} + /** * Specifies the base service URIs used by SDK components. */ @@ -11,6 +15,7 @@ export default class ServiceEndpoints { public readonly streaming: string; public readonly polling: string; public readonly events: string; + public readonly payloadFilterKey?: string; /** Valid paths are: * /bulk @@ -36,6 +41,7 @@ export default class ServiceEndpoints { analyticsEventPath: string = '/bulk', diagnosticEventPath: string = '/diagnostic', includeAuthorizationHeader: boolean = true, + payloadFilterKey?: string, ) { this.streaming = canonicalizeUri(streaming); this.polling = canonicalizeUri(polling); @@ -43,5 +49,76 @@ export default class ServiceEndpoints { this.analyticsEventPath = analyticsEventPath; this.diagnosticEventPath = diagnosticEventPath; this.includeAuthorizationHeader = includeAuthorizationHeader; + this.payloadFilterKey = payloadFilterKey; } } + +function getWithParams(uri: string, parameters: { key: string; value: string }[]) { + if (parameters.length === 0) { + return uri; + } + + const parts = parameters.map(({ key, value }) => `${key}=${value}`); + return `${uri}?${parts.join('&')}`; +} + +/** + * Get the URI for the streaming endpoint. + * + * @param endpoints The service endpoints. + * @param path The path to the resource, devoid of any query parameters or hrefs. + * @param parameters The query parameters. These query parameters must already have the appropriate encoding applied. This function WILL NOT apply it for you. + */ +export function getStreamingUri( + endpoints: ServiceEndpoints, + path: string, + parameters: { key: string; value: string }[], +): string { + const canonicalizedPath = canonicalizePath(path); + + const combinedParameters = [...parameters]; + if (endpoints.payloadFilterKey) { + combinedParameters.push({ key: 'filter', value: endpoints.payloadFilterKey }); + } + + return getWithParams(`${endpoints.streaming}/${canonicalizedPath}`, combinedParameters); +} + +/** + * Get the URI for the polling endpoint. + * + * @param endpoints The service endpoints. + * @param path The path to the resource, devoid of any query parameters or hrefs. + * @param parameters The query parameters. These query parameters must already have the appropriate encoding applied. This function WILL NOT apply it for you. + */ +export function getPollingUri( + endpoints: ServiceEndpoints, + path: string, + parameters: { key: string; value: string }[], +): string { + const canonicalizedPath = canonicalizePath(path); + + const combinedParameters = [...parameters]; + if (endpoints.payloadFilterKey) { + combinedParameters.push({ key: 'filter', value: endpoints.payloadFilterKey }); + } + + return getWithParams(`${endpoints.polling}/${canonicalizedPath}`, combinedParameters); +} + +/** + * Get the URI for the events endpoint. + * + * @param endpoints The service endpoints. + * @param path The path to the resource, devoid of any query parameters or hrefs. + * @param parameters The query parameters. These query parameters must already have the appropriate encoding applied. This function WILL NOT apply it for you. + */ +export function getEventsUri( + endpoints: ServiceEndpoints, + path: string, + parameters: { key: string; value: string }[], +): string { + const canonicalizedPath = canonicalizePath(path); + + return getWithParams(`${endpoints.events}/${canonicalizedPath}`, parameters); +} diff --git a/packages/shared/common/src/options/index.ts b/packages/shared/common/src/options/index.ts index 17d7859e3..04181131c 100644 --- a/packages/shared/common/src/options/index.ts +++ b/packages/shared/common/src/options/index.ts @@ -1,6 +1,14 @@ import ApplicationTags from './ApplicationTags'; import ClientContext from './ClientContext'; import OptionMessages from './OptionMessages'; -import ServiceEndpoints from './ServiceEndpoints'; +import ServiceEndpoints, { getEventsUri, getPollingUri, getStreamingUri } from './ServiceEndpoints'; -export { ApplicationTags, OptionMessages, ServiceEndpoints, ClientContext }; +export { + ApplicationTags, + OptionMessages, + ServiceEndpoints, + ClientContext, + getStreamingUri, + getPollingUri, + getEventsUri, +}; diff --git a/packages/shared/common/src/validators.ts b/packages/shared/common/src/validators.ts index 5ae8f0c0c..d294643bd 100644 --- a/packages/shared/common/src/validators.ts +++ b/packages/shared/common/src/validators.ts @@ -118,7 +118,7 @@ export class StringMatchingRegex extends Type { } override is(u: unknown): u is string { - return !!(u as string).match(this.expression); + return typeof u === 'string' && !!(u as string).match(this.expression); } } diff --git a/packages/shared/mocks/src/platform.ts b/packages/shared/mocks/src/platform.ts index 3fe5ecfef..ef138ebd5 100644 --- a/packages/shared/mocks/src/platform.ts +++ b/packages/shared/mocks/src/platform.ts @@ -1,11 +1,7 @@ -import type { Encoding, Platform, PlatformData, Requests, SdkData, Storage } from '@common'; +import type { PlatformData, SdkData } from '@common'; import { setupCrypto } from './crypto'; -const encoding: Encoding = { - btoa: (s: string) => Buffer.from(s).toString('base64'), -}; - const setupInfo = () => ({ platformData: jest.fn( (): PlatformData => ({ diff --git a/packages/shared/mocks/src/streamingProcessor.ts b/packages/shared/mocks/src/streamingProcessor.ts index 2de19efc8..e596b443f 100644 --- a/packages/shared/mocks/src/streamingProcessor.ts +++ b/packages/shared/mocks/src/streamingProcessor.ts @@ -25,6 +25,7 @@ export const setupMockStreamingProcessor = ( sdkKey: string, clientContext: ClientContext, streamUriPath: string, + parameters: { key: string; value: string }[], listeners: Map, diagnosticsManager: internal.DiagnosticsManager, errorHandler: internal.StreamingErrorHandler, diff --git a/packages/shared/sdk-client/src/LDClientImpl.test.ts b/packages/shared/sdk-client/src/LDClientImpl.test.ts index 6fd145f61..34008c497 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.test.ts @@ -112,6 +112,7 @@ describe('sdk-client object', () => { expect.anything(), '/stream/path', expect.anything(), + expect.anything(), undefined, expect.anything(), ); @@ -130,7 +131,8 @@ describe('sdk-client object', () => { expect(MockStreamingProcessor).toHaveBeenCalledWith( expect.anything(), expect.anything(), - '/stream/path?withReasons=true', + '/stream/path', + [{ key: 'withReasons', value: 'true' }], expect.anything(), undefined, expect.anything(), diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index cc8b33ad2..beac5c1c0 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -401,15 +401,17 @@ export default class LDClientImpl implements LDClient { identifyResolve: any, identifyReject: any, ) { - let pollingPath = this.createPollUriPath(context); + const parameters: { key: string; value: string }[] = []; if (this.config.withReasons) { - pollingPath = `${pollingPath}?withReasons=true`; + parameters.push({ key: 'withReasons', value: 'true' }); } + this.updateProcessor = new PollingProcessor( this.sdkKey, this.clientContext.platform.requests, this.clientContext.platform.info, - pollingPath, + this.createPollUriPath(context), + parameters, this.config, async (flags) => { this.logger.debug(`Handling polling result: ${Object.keys(flags)}`); @@ -438,15 +440,16 @@ export default class LDClientImpl implements LDClient { identifyResolve: any, identifyReject: any, ) { - let streamingPath = this.createStreamUriPath(context); + const parameters: { key: string; value: string }[] = []; if (this.config.withReasons) { - streamingPath = `${streamingPath}?withReasons=true`; + parameters.push({ key: 'withReasons', value: 'true' }); } this.updateProcessor = new internal.StreamingProcessor( this.sdkKey, this.clientContext, - streamingPath, + this.createStreamUriPath(context), + parameters, this.createStreamListeners(checkedContext, identifyResolve), this.diagnosticsManager, (e) => { diff --git a/packages/shared/sdk-client/src/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts index 97de4f8b1..8d098c881 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -224,4 +224,20 @@ export interface LDOptions { * If `wrapperName` is unset, this field will be ignored. */ wrapperVersion?: string; + + /** + * LaunchDarkly Server SDKs historically downloaded all flag configuration and segments for a particular environment + * during initialization. + * + * For some customers, this is an unacceptably large amount of data, and has contributed to performance issues + * within their products. + * + * Filtered environments aim to solve this problem. By allowing customers to specify subsets of an environment's + * flags using a filter key, SDKs will initialize faster and use less memory. + * + * This payload filter key only applies to the default streaming and polling data sources. It will not affect + * TestData or FileData data sources, nor will it be applied to any data source provided through the featureStore + * config property. + */ + payloadFilterKey?: string; } diff --git a/packages/shared/sdk-client/src/configuration/Configuration.test.ts b/packages/shared/sdk-client/src/configuration/Configuration.test.ts index e5b18e59c..9936234f9 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.test.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.test.ts @@ -7,7 +7,7 @@ describe('Configuration', () => { console.error = jest.fn(); }); - test('defaults', () => { + it('has valid default values', () => { const config = new Configuration(); expect(config).toMatchObject({ @@ -36,12 +36,12 @@ describe('Configuration', () => { expect(console.error).not.toHaveBeenCalled(); }); - test('specified options should be set', () => { + it('allows specifying valid wrapperName', () => { const config = new Configuration({ wrapperName: 'test' }); expect(config).toMatchObject({ wrapperName: 'test' }); }); - test('unknown option', () => { + it('warns and ignored invalid keys', () => { // @ts-ignore const config = new Configuration({ baseballUri: 1 }); @@ -49,7 +49,7 @@ describe('Configuration', () => { expect(console.error).toHaveBeenCalledWith(expect.stringContaining('unknown config option')); }); - test('wrong type for boolean should be converted', () => { + it('converts boolean types', () => { // @ts-ignore const config = new Configuration({ sendEvents: 0 }); @@ -59,7 +59,7 @@ describe('Configuration', () => { ); }); - test('wrong type for number should use default', () => { + it('ignores wrong type for number and logs appropriately', () => { // @ts-ignore const config = new Configuration({ capacity: true }); @@ -69,7 +69,7 @@ describe('Configuration', () => { ); }); - test('enforce minimum flushInterval', () => { + it('enforces minimum flushInterval', () => { const config = new Configuration({ flushInterval: 1 }); expect(config.flushInterval).toEqual(2); @@ -79,14 +79,14 @@ describe('Configuration', () => { ); }); - test('recognize maxCachedContexts', () => { + it('allows setting a valid maxCachedContexts', () => { const config = new Configuration({ maxCachedContexts: 3 }); expect(config.maxCachedContexts).toBeDefined(); expect(console.error).not.toHaveBeenCalled(); }); - test('enforce minimum maxCachedContext', () => { + it('enforces minimum maxCachedContext', () => { const config = new Configuration({ maxCachedContexts: -1 }); expect(config.maxCachedContexts).toBeDefined(); @@ -95,4 +95,28 @@ describe('Configuration', () => { expect.stringContaining('had invalid value of -1'), ); }); + + it.each([ + ['1'], + ['camelCaseWorks'], + ['PascalCaseWorks'], + ['kebab-case-works'], + ['snake_case_works'], + ])('allow setting valid payload filter keys', (filter) => { + const config = new Configuration({ payloadFilterKey: filter }); + expect(config.payloadFilterKey).toEqual(filter); + expect(console.error).toHaveBeenCalledTimes(0); + }); + + it.each([['invalid-@-filter'], ['_invalid-filter'], ['-invalid-filter']])( + 'ignores invalid filters and logs a warning', + (filter) => { + const config = new Configuration({ payloadFilterKey: filter }); + expect(config.payloadFilterKey).toBeUndefined(); + expect(console.error).toHaveBeenNthCalledWith( + 1, + expect.stringMatching(/should be of type string matching/i), + ); + }, + ); }); diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index 8d4507dcc..55d87f879 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -78,6 +78,7 @@ export default class Configuration { internalOptions.analyticsEventPath, internalOptions.diagnosticEventPath, internalOptions.includeAuthorizationHeader, + pristineOptions.payloadFilterKey, ); this.tags = new ApplicationTags({ application: this.applicationInfo, logger: this.logger }); } diff --git a/packages/shared/sdk-client/src/configuration/validators.ts b/packages/shared/sdk-client/src/configuration/validators.ts index 7b3aaddc9..cc22874b4 100644 --- a/packages/shared/sdk-client/src/configuration/validators.ts +++ b/packages/shared/sdk-client/src/configuration/validators.ts @@ -40,6 +40,7 @@ const validators: Record = { applicationInfo: TypeValidators.Object, wrapperName: TypeValidators.String, wrapperVersion: TypeValidators.String, + payloadFilterKey: TypeValidators.stringMatchingRegex(/^[a-zA-Z0-9](\w|\.|-)*$/), }; export default validators; diff --git a/packages/shared/sdk-client/src/polling/PollingProcessor.ts b/packages/shared/sdk-client/src/polling/PollingProcessor.ts index 23fe35770..2b95f27fd 100644 --- a/packages/shared/sdk-client/src/polling/PollingProcessor.ts +++ b/packages/shared/sdk-client/src/polling/PollingProcessor.ts @@ -1,5 +1,6 @@ import { ApplicationTags, + getPollingUri, httpErrorMessage, HttpErrorResponse, Info, @@ -48,11 +49,12 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { requests: Requests, info: Info, uriPath: string, + parameters: { key: string; value: string }[], config: PollingConfig, private readonly dataHandler: (flags: Flags) => void, private readonly errorHandler?: PollingErrorHandler, ) { - const uri = `${config.serviceEndpoints.polling}${uriPath}`; + const uri = getPollingUri(config.serviceEndpoints, uriPath, parameters); this.logger = config.logger; this.pollInterval = config.pollInterval; diff --git a/packages/shared/sdk-client/src/polling/PollingProcessot.test.ts b/packages/shared/sdk-client/src/polling/PollingProcessot.test.ts index 6114f42a5..ce0a6a8e0 100644 --- a/packages/shared/sdk-client/src/polling/PollingProcessot.test.ts +++ b/packages/shared/sdk-client/src/polling/PollingProcessot.test.ts @@ -86,6 +86,7 @@ it('makes no requests until it is started', () => { requests, makeInfo(), '/polling', + [], makeConfig(), (_flags) => {}, (_error) => {}, @@ -102,6 +103,7 @@ it('polls immediately when started', () => { requests, makeInfo(), '/polling', + [], makeConfig(), (_flags) => {}, (_error) => {}, @@ -122,6 +124,7 @@ it('calls callback on success', async () => { requests, makeInfo(), '/polling', + [], makeConfig(), dataCallback, errorCallback, @@ -143,6 +146,7 @@ it('polls repeatedly', async () => { requests, makeInfo(), '/polling', + [], makeConfig({ pollInterval: 0.1 }), dataCallback, errorCallback, @@ -174,6 +178,7 @@ it('stops polling when stopped', (done) => { requests, makeInfo(), '/stops', + [], makeConfig({ pollInterval: 0.01 }), dataCallback, errorCallback, @@ -199,6 +204,7 @@ it('includes the correct headers on requests', () => { version: '42', }), '/polling', + [], makeConfig(), (_flags) => {}, (_error) => {}, @@ -225,6 +231,7 @@ it('defaults to using the "GET" verb', () => { requests, makeInfo(), '/polling', + [], makeConfig(), (_flags) => {}, (_error) => {}, @@ -248,6 +255,7 @@ it('can be configured to use the "REPORT" verb', () => { requests, makeInfo(), '/polling', + [], makeConfig({ useReport: true }), (_flags) => {}, (_error) => {}, @@ -274,6 +282,7 @@ it('continues polling after receiving bad JSON', async () => { requests, makeInfo(), '/polling', + [], config, dataCallback, errorCallback, @@ -302,6 +311,7 @@ it('continues polling after an exception thrown during a request', async () => { requests, makeInfo(), '/polling', + [], config, dataCallback, errorCallback, @@ -333,6 +343,7 @@ it('can handle recoverable http errors', async () => { requests, makeInfo(), '/polling', + [], config, dataCallback, errorCallback, @@ -362,6 +373,7 @@ it('stops polling on unrecoverable error codes', (done) => { requests, makeInfo(), '/polling', + [], config, dataCallback, errorCallback, diff --git a/packages/shared/sdk-server/__tests__/options/Configuration.test.ts b/packages/shared/sdk-server/__tests__/options/Configuration.test.ts index 03b6fa27d..399439be7 100644 --- a/packages/shared/sdk-server/__tests__/options/Configuration.test.ts +++ b/packages/shared/sdk-server/__tests__/options/Configuration.test.ts @@ -41,6 +41,7 @@ describe.each([undefined, null, 'potat0', 17, [], {}])('constructed without opti expect(config.wrapperName).toBeUndefined(); expect(config.wrapperVersion).toBeUndefined(); expect(config.hooks).toBeUndefined(); + expect(config.payloadFilterKey).toBeUndefined(); }); }); @@ -329,6 +330,34 @@ describe('when setting different options', () => { logger(config).expectMessages(logs); }); + it.each([ + ['1', '1', []], + ['camelCaseWorks', 'camelCaseWorks', []], + ['PascalCaseWorks', 'PascalCaseWorks', []], + ['kebab-case-works', 'kebab-case-works', []], + ['snake_case_works', 'snake_case_works', []], + [ + 'invalid-@-filter', + undefined, + [{ level: LogLevel.Warn, matches: /Config option "payloadFilterKey" should be of type/ }], + ], + [ + '_invalid-filter', + undefined, + [{ level: LogLevel.Warn, matches: /Config option "payloadFilterKey" should be of type/ }], + ], + [ + '-invalid-filter', + undefined, + [{ level: LogLevel.Warn, matches: /Config option "payloadFilterKey" should be of type/ }], + ], + ])('allow setting and validates payloadFilterKey', (filter, expected, logs) => { + // @ts-ignore + const config = new Configuration(withLogger({ payloadFilterKey: filter })); + expect(config.payloadFilterKey).toEqual(expected); + logger(config).expectMessages(logs); + }); + it('discards unrecognized options with a warning', () => { // @ts-ignore const config = new Configuration(withLogger({ yes: 'no', cat: 'yes' })); diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index de271ea36..688a36b40 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -217,6 +217,7 @@ export default class LDClientImpl implements LDClient { sdkKey, clientContext, '/all', + [], listeners, this.diagnosticsManager, (e) => this.dataSourceErrorHandler(e), diff --git a/packages/shared/sdk-server/src/api/options/LDOptions.ts b/packages/shared/sdk-server/src/api/options/LDOptions.ts index a0d38b0fa..769783973 100644 --- a/packages/shared/sdk-server/src/api/options/LDOptions.ts +++ b/packages/shared/sdk-server/src/api/options/LDOptions.ts @@ -268,6 +268,22 @@ export interface LDOptions { * ASCII digits, period, hyphen, underscore. A string containing any other characters will be ignored. */ versionName?: string; + + /** + * LaunchDarkly Server SDKs historically downloaded all flag configuration and segments for a particular environment + * during initialization. + * + * For some customers, this is an unacceptably large amount of data, and has contributed to performance issues + * within their products. + * + * Filtered environments aim to solve this problem. By allowing customers to specify subsets of an environment's + * flags using a filter key, SDKs will initialize faster and use less memory. + * + * This payload filter key only applies to the default streaming and polling data sources. It will not affect + * TestData or FileData data sources, nor will it be applied to any data source provided through the featureStore + * config property. + */ + payloadFilterKey?: string; }; /** diff --git a/packages/shared/sdk-server/src/data_sources/Requestor.ts b/packages/shared/sdk-server/src/data_sources/Requestor.ts index 6ffb2200d..d6498c604 100644 --- a/packages/shared/sdk-server/src/data_sources/Requestor.ts +++ b/packages/shared/sdk-server/src/data_sources/Requestor.ts @@ -1,5 +1,6 @@ import { defaultHeaders, + getPollingUri, Info, LDStreamingError, Options, @@ -33,7 +34,7 @@ export default class Requestor implements LDFeatureRequestor { private readonly requests: Requests, ) { this.headers = defaultHeaders(sdkKey, info, config.tags); - this.uri = `${config.serviceEndpoints.polling}/sdk/latest-all`; + this.uri = getPollingUri(config.serviceEndpoints, '/sdk/latest-all', []); } /** diff --git a/packages/shared/sdk-server/src/options/Configuration.ts b/packages/shared/sdk-server/src/options/Configuration.ts index 9e68a4f20..77baf0de3 100644 --- a/packages/shared/sdk-server/src/options/Configuration.ts +++ b/packages/shared/sdk-server/src/options/Configuration.ts @@ -55,6 +55,7 @@ const validations: Record = { wrapperName: TypeValidators.String, wrapperVersion: TypeValidators.String, application: TypeValidators.Object, + payloadFilterKey: TypeValidators.stringMatchingRegex(/^[a-zA-Z0-9](\w|\.|-)*$/), hooks: TypeValidators.createTypeArray('Hook[]', {}), }; @@ -197,6 +198,8 @@ export default class Configuration { public readonly tags: ApplicationTags; + public readonly payloadFilterKey?: string; + public readonly diagnosticRecordingInterval: number; public readonly featureStoreFactory: (clientContext: LDClientContext) => LDFeatureStore; @@ -234,6 +237,7 @@ export default class Configuration { internalOptions.analyticsEventPath, internalOptions.diagnosticEventPath, internalOptions.includeAuthorizationHeader, + validatedOptions.payloadFilterKey, ); this.eventsCapacity = validatedOptions.capacity; this.timeout = validatedOptions.timeout; @@ -255,6 +259,7 @@ export default class Configuration { this.tlsParams = validatedOptions.tlsParams; this.diagnosticOptOut = validatedOptions.diagnosticOptOut; this.wrapperName = validatedOptions.wrapperName; + this.payloadFilterKey = validatedOptions.payloadFilterKey; this.wrapperVersion = validatedOptions.wrapperVersion; this.tags = new ApplicationTags(validatedOptions); this.diagnosticRecordingInterval = validatedOptions.diagnosticRecordingInterval;