diff --git a/CHANGES.txt b/CHANGES.txt index 0bb2cde5..a893bd27 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,13 @@ +1.11.0 (November 3, 2023) + - Added support for Flag Sets on the SDK, which enables grouping feature flags and interacting with the group rather than individually (more details in our documentation): + - Added new variations of the get treatment methods to support evaluating flags in given flag set/s. + - getTreatmentsByFlagSet and getTreatmentsByFlagSets + - getTreatmentsWithConfigByFlagSets and getTreatmentsWithConfigByFlagSets + - Added a new optional Split Filter configuration option. This allows the SDK and Split services to only synchronize the flags in the specified flag sets, avoiding unused or unwanted flags from being synced on the SDK instance, bringing all the benefits from a reduced payload. + - Note: Only applicable when the SDK is in charge of the rollout data synchronization. When not applicable, the SDK will log a warning on init. + - Added `sets` property to the `SplitView` object returned by the `split` and `splits` methods of the SDK manager to expose flag sets on flag views. + - Bugfixing - Fixed SDK key validation in NodeJS to ensure the SDK_READY_TIMED_OUT event is emitted when a client-side type SDK key is provided instead of a server-side one (Related to issue https://github.com/splitio/javascript-client/issues/768). + 1.10.0 (October 20, 2023) - Added `defaultTreatment` property to the `SplitView` object returned by the `split` and `splits` methods of the SDK manager (Related to issue https://github.com/splitio/javascript-commons/issues/225). - Updated log warning message to include the feature flag name when `getTreatment` method is called and the SDK client is not ready. diff --git a/package-lock.json b/package-lock.json index f2dd4391..5c28d3a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-commons", - "version": "1.10.0", + "version": "1.11.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-commons", - "version": "1.10.0", + "version": "1.11.0", "license": "Apache-2.0", "dependencies": { "tslib": "^2.3.1" diff --git a/package.json b/package.json index 889dea78..2fda9f0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "1.10.0", + "version": "1.11.0", "description": "Split Javascript SDK common components", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/src/__tests__/mocks/fetchSpecificSplits.ts b/src/__tests__/mocks/fetchSpecificSplits.ts index 8f9098c3..4b2f4c90 100644 --- a/src/__tests__/mocks/fetchSpecificSplits.ts +++ b/src/__tests__/mocks/fetchSpecificSplits.ts @@ -10,6 +10,13 @@ const valuesExamples = [ ['p0', 'p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', 'p8', 'p9', 'p10', 'p11', 'p12', 'p13', 'p14', 'p15', 'p16', 'p17', 'p18', 'p19', 'p20', 'p21', 'p22', 'p23', 'p24', 'p25', 'p26', 'p27', 'p28', 'p29', 'p30', 'p31', 'p32', 'p33', 'p34', 'p35', 'p36', 'p37', 'p38', 'p39', 'p40', 'p41', 'p42', 'p43', 'p44', 'p45', 'p46', 'p47', 'p48', 'p49', 'p50'], ['__ш', '__a', '%', '%25', ' __ш ', '% '], // to test that we order before encoding: '__a' < '__ш' but encodeURIComponent('__a') > encodeURIComponent('__ш') ['%', '%25', '__a', '__ш'], // [7] ordered and deduplicated + // flagSets examples + [' set_1','set_3 ',' set_a ','set_2','set_c','set_b'], // [9] trim + ['set_1','set_2','set_3','set_a','set_b','set_c'], // [10] sanitized [9] + ['set_ 1','set _3','3set_a','_set_2','seT_c','set_B','set_1234567890_1234567890_234567890_1234567890_1234567890','set_a','set_2'], // [11] lowercase & regexp + ['3set_a','set_2','set_a','set_b','set_c'], // [12] sanitized [11] + ['set_2','set_a','SET_2','set_a','set_b','set_B','set_1','set_3!'], // [13] dedupe, dedupe with case sensitive + ['set_1','set_2','set_a','set_b'], // [14] sanitized [13] ]; export const splitFilters: SplitIO.SplitFilter[][] = [ @@ -41,39 +48,79 @@ export const splitFilters: SplitIO.SplitFilter[][] = [ ], [ { type: 'byName', values: valuesExamples[7] } - ] + ], + // FlagSet filters + [ // [6] + { type: 'byPrefix', values: valuesExamples[1] }, + { type: 'bySet', values: valuesExamples[9] }, + { type: 'byName', values: valuesExamples[1] } + ], + [ // [7] + { type: 'bySet', values: valuesExamples[11] }, + { type: 'byPrefix', values: [] }, + { type: 'byName', values: valuesExamples[6] } + ], + [ // [8] + { type: 'byPrefix', values: [] }, + { type: 'byName', values: valuesExamples[6] }, + { type: 'bySet', values: valuesExamples[13] } + ], ]; // each entry corresponds to the queryString or exception message of each splitFilters entry export const queryStrings = [ - '&names=abc%C8%A3,abc%C8%A3asd,ausgef%C3%BCllt,%C8%A3abc', - '&prefixes=abc%C8%A3,abc%C8%A3asd,ausgef%C3%BCllt,%C8%A3abc', - '&names=abc%C8%A3,abc%C8%A3asd,ausgef%C3%BCllt,%C8%A3abc&prefixes=abc%C8%A3,abc%C8%A3asd,ausgef%C3%BCllt,%C8%A3abc', - "400 unique values can be specified at most for 'byName' filter. You passed 401. Please consider reducing the amount or using other filter.", - "50 unique values can be specified at most for 'byPrefix' filter. You passed 51. Please consider reducing the amount or using other filter.", - '&names=%25,%2525,__a,__%D1%88', + '&names=abc%C8%A3,abc%C8%A3asd,ausgef%C3%BCllt,%C8%A3abc', // [0] + '&prefixes=abc%C8%A3,abc%C8%A3asd,ausgef%C3%BCllt,%C8%A3abc', // [1] + '&names=abc%C8%A3,abc%C8%A3asd,ausgef%C3%BCllt,%C8%A3abc&prefixes=abc%C8%A3,abc%C8%A3asd,ausgef%C3%BCllt,%C8%A3abc', // [2] + "400 unique values can be specified at most for 'byName' filter. You passed 401. Please consider reducing the amount or using other filter.", // [3] + "50 unique values can be specified at most for 'byPrefix' filter. You passed 51. Please consider reducing the amount or using other filter.", // [4] + '&names=%25,%2525,__a,__%D1%88', // [5] + // FlagSet filters + '&sets=set_1,set_2,set_3,set_a,set_b,set_c', // [6] + '&sets=3set_a,set_2,set_a,set_b,set_c', // [7] + '&sets=set_1,set_2,set_a,set_b', // [8] ]; // each entry corresponds to a `groupedFilter` object returned by `validateSplitFilter` for each `splitFilters` input. // `groupedFilter` contains valid, unique and ordered values per filter type. // An `undefined` value means that `validateSplitFilter` throws an exception which message value is at `queryStrings`. export const groupedFilters = [ - { + { // [0] + bySet: [], byName: valuesExamples[2], byPrefix: [] }, - { + { // [1] + bySet: [], byName: [], byPrefix: valuesExamples[2] }, - { + { // [2] + bySet: [], byName: valuesExamples[2], byPrefix: valuesExamples[2] }, - undefined, - undefined, - { + undefined, // [3] + undefined, // [4] + { // [5] + bySet: [], byName: valuesExamples[8], byPrefix: [] - } + }, + // FlagSet filters + { // [6] + byName: [], + bySet: valuesExamples[10], + byPrefix: [] + }, + { // [7] + byName: [], + bySet: valuesExamples[12], + byPrefix: [] + }, + { // [8] + byName: [], + bySet: valuesExamples[14], + byPrefix: [] + }, ]; diff --git a/src/dtos/types.ts b/src/dtos/types.ts index a61e3777..03363928 100644 --- a/src/dtos/types.ts +++ b/src/dtos/types.ts @@ -163,7 +163,8 @@ export interface ISplit { trafficAllocationSeed?: number configurations?: { [treatmentName: string]: string - } + }, + sets?: string[] } // Split definition used in offline mode @@ -208,5 +209,5 @@ export interface IMetadata { export type ISplitFiltersValidation = { queryString: string | null, groupedFilters: Record, - validFilters: SplitIO.SplitFilter[] + validFilters: SplitIO.SplitFilter[], }; diff --git a/src/evaluator/__tests__/evaluate-features.spec.ts b/src/evaluator/__tests__/evaluate-features.spec.ts index c400c983..3b81a37b 100644 --- a/src/evaluator/__tests__/evaluate-features.spec.ts +++ b/src/evaluator/__tests__/evaluate-features.spec.ts @@ -1,7 +1,9 @@ // @ts-nocheck -import { evaluateFeatures } from '../index'; +import { evaluateFeatures, evaluateFeaturesByFlagSets } from '../index'; import * as LabelsConstants from '../../utils/labels'; import { loggerMock } from '../../logger/__tests__/sdkLogger.mock'; +import { _Set } from '../../utils/lang/sets'; +import { returnSetsUnion } from '../../utils/lang/sets'; const splitsMock = { regular: { 'changeNumber': 1487277320548, 'trafficAllocationSeed': 1667452163, 'trafficAllocation': 100, 'trafficTypeName': 'user', 'name': 'always-on', 'seed': 1684183541, 'configurations': {}, 'status': 'ACTIVE', 'killed': false, 'defaultTreatment': 'off', 'conditions': [{ 'conditionType': 'ROLLOUT', 'matcherGroup': { 'combiner': 'AND', 'matchers': [{ 'keySelector': { 'trafficType': 'user', 'attribute': '' }, 'matcherType': 'ALL_KEYS', 'negate': false, 'userDefinedSegmentMatcherData': { 'segmentName': '' }, 'unaryNumericMatcherData': { 'dataType': '', 'value': 0 }, 'whitelistMatcherData': { 'whitelist': null }, 'betweenMatcherData': { 'dataType': '', 'start': 0, 'end': 0 } }] }, 'partitions': [{ 'treatment': 'on', 'size': 100 }, { 'treatment': 'off', 'size': 0 }], 'label': 'in segment all' }] }, @@ -14,6 +16,11 @@ const splitsMock = { trafficAlocation1WithConfig: { 'changeNumber': 1487277320548, 'trafficAllocationSeed': -1667452163, 'trafficAllocation': 1, 'trafficTypeName': 'user', 'name': 'always-on6', 'seed': 1684183541, 'configurations': { 'off': "{color:'black'}" }, 'status': 'ACTIVE', 'killed': false, 'defaultTreatment': 'off', 'conditions': [{ 'conditionType': 'ROLLOUT', 'matcherGroup': { 'combiner': 'AND', 'matchers': [{ 'keySelector': { 'trafficType': 'user', 'attribute': '' }, 'matcherType': 'ALL_KEYS', 'negate': false, 'userDefinedSegmentMatcherData': { 'segmentName': '' }, 'unaryNumericMatcherData': { 'dataType': '', 'value': 0 }, 'whitelistMatcherData': { 'whitelist': null }, 'betweenMatcherData': { 'dataType': '', 'start': 0, 'end': 0 } }] }, 'partitions': [{ 'treatment': 'on', 'size': 100 }, { 'treatment': 'off', 'size': 0 }], 'label': 'in segment all' }] } }; +const flagSetsMock = { + reg_and_config: new _Set(['regular', 'config']), + arch_and_killed: new _Set(['killed', 'archived']), +}; + const mockStorage = { splits: { getSplit(name) { @@ -29,6 +36,16 @@ const mockStorage = { }); return splits; + }, + getNamesByFlagSets(flagSets) { + let toReturn = new _Set([]); + flagSets.forEach(flagset => { + const featureFlagNames = flagSetsMock[flagset]; + if (featureFlagNames) { + toReturn = returnSetsUnion(toReturn, featureFlagNames); + } + }); + return toReturn; } } }; @@ -105,3 +122,57 @@ test('EVALUATOR - Multiple evaluations at once / should return right labels, tre // If the split is retrieved but is not in split (out of Traffic Allocation), we should get the right evaluation result, label and config. }); + +test('EVALUATOR - Multiple evaluations at once by flag sets / should return right labels, treatments and configs if storage returns without errors.', async function () { + + const expectedOutput = { + config: { + treatment: 'on', label: 'in segment all', + config: '{color:\'black\'}', changeNumber: 1487277320548 + }, + not_existent_split: { + treatment: 'control', label: LabelsConstants.SPLIT_NOT_FOUND, config: null + }, + }; + + const getResultsByFlagsets = (flagSets: string[]) => { + return evaluateFeaturesByFlagSets( + loggerMock, + 'fake-key', + flagSets, + null, + mockStorage, + ); + }; + + + + let multipleEvaluationAtOnceByFlagSets = await getResultsByFlagsets(['reg_and_config', 'arch_and_killed']); + + // assert evaluationWithConfig + expect(multipleEvaluationAtOnceByFlagSets['config']).toEqual(expectedOutput['config']); // If the split is retrieved successfully we should get the right evaluation result, label and config. + // @todo assert flag set not found - for input validations + + // assert regular + expect(multipleEvaluationAtOnceByFlagSets['regular']).toEqual({ ...expectedOutput['config'], config: null }); // If the split is retrieved successfully we should get the right evaluation result, label and config. If Split has no config it should have config equal null. + // assert killed + expect(multipleEvaluationAtOnceByFlagSets['killed']).toEqual({ ...expectedOutput['config'], treatment: 'off', config: null, label: LabelsConstants.SPLIT_KILLED }); + // 'If the split is retrieved but is killed, we should get the right evaluation result, label and config. + + // assert archived + expect(multipleEvaluationAtOnceByFlagSets['archived']).toEqual({ ...expectedOutput['config'], treatment: 'control', label: LabelsConstants.SPLIT_ARCHIVED, config: null }); + // If the split is retrieved but is archived, we should get the right evaluation result, label and config. + + // assert not_existent_split not in evaluation if it is not related to defined flag sets + expect(multipleEvaluationAtOnceByFlagSets['not_existent_split']).toEqual(undefined); + + multipleEvaluationAtOnceByFlagSets = await getResultsByFlagsets([]); + expect(multipleEvaluationAtOnceByFlagSets).toEqual({}); + + multipleEvaluationAtOnceByFlagSets = await getResultsByFlagsets(['reg_and_config']); + expect(multipleEvaluationAtOnceByFlagSets['config']).toEqual(expectedOutput['config']); + expect(multipleEvaluationAtOnceByFlagSets['regular']).toEqual({ ...expectedOutput['config'], config: null }); + expect(multipleEvaluationAtOnceByFlagSets['killed']).toEqual(undefined); + expect(multipleEvaluationAtOnceByFlagSets['archived']).toEqual(undefined); + +}); diff --git a/src/evaluator/index.ts b/src/evaluator/index.ts index dad4c9e4..62f82e98 100644 --- a/src/evaluator/index.ts +++ b/src/evaluator/index.ts @@ -7,6 +7,7 @@ import { IStorageAsync, IStorageSync } from '../storages/types'; import { IEvaluationResult } from './types'; import { SplitIO } from '../types'; import { ILogger } from '../logger/types'; +import { ISet, setToArray } from '../utils/lang/sets'; const treatmentException = { treatment: CONTROL, @@ -87,6 +88,32 @@ export function evaluateFeatures( getEvaluations(log, splitNames, parsedSplits, key, attributes, storage); } +export function evaluateFeaturesByFlagSets( + log: ILogger, + key: SplitIO.SplitKey, + flagSets: string[], + attributes: SplitIO.Attributes | undefined, + storage: IStorageSync | IStorageAsync, +): MaybeThenable> { + let storedFlagNames: MaybeThenable>; + + // get features by flag sets + try { + storedFlagNames = storage.splits.getNamesByFlagSets(flagSets); + } catch (e) { + // return empty evaluations + return {}; + } + + // evaluate related features + return thenable(storedFlagNames) ? + storedFlagNames.then((splitNames) => evaluateFeatures(log, key, setToArray(splitNames), attributes, storage)) + .catch(() => { + return {}; + }) : + evaluateFeatures(log, key, setToArray(storedFlagNames), attributes, storage); +} + function getEvaluation( log: ILogger, splitJSON: ISplit | null, diff --git a/src/evaluator/matchers/__tests__/dependency.spec.ts b/src/evaluator/matchers/__tests__/dependency.spec.ts index 6ec2b0dc..926750ae 100644 --- a/src/evaluator/matchers/__tests__/dependency.spec.ts +++ b/src/evaluator/matchers/__tests__/dependency.spec.ts @@ -6,8 +6,8 @@ import { IStorageSync } from '../../../storages/types'; import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; import { ISplit } from '../../../dtos/types'; -const ALWAYS_ON_SPLIT = { 'trafficTypeName': 'user', 'name': 'always-on', 'trafficAllocation': 100, 'trafficAllocationSeed': 1012950810, 'seed': -725161385, 'status': 'ACTIVE', 'killed': false, 'defaultTreatment': 'off', 'changeNumber': 1494364996459, 'algo': 2, 'conditions': [{ 'conditionType': 'ROLLOUT', 'matcherGroup': { 'combiner': 'AND', 'matchers': [{ 'keySelector': { 'trafficType': 'user', 'attribute': null }, 'matcherType': 'ALL_KEYS', 'negate': false, 'userDefinedSegmentMatcherData': null, 'whitelistMatcherData': null, 'unaryNumericMatcherData': null, 'betweenMatcherData': null }] }, 'partitions': [{ 'treatment': 'on', 'size': 100 }, { 'treatment': 'off', 'size': 0 }], 'label': 'in segment all' }] } as ISplit; -const ALWAYS_OFF_SPLIT = { 'trafficTypeName': 'user', 'name': 'always-off', 'trafficAllocation': 100, 'trafficAllocationSeed': -331690370, 'seed': 403891040, 'status': 'ACTIVE', 'killed': false, 'defaultTreatment': 'on', 'changeNumber': 1494365020316, 'algo': 2, 'conditions': [{ 'conditionType': 'ROLLOUT', 'matcherGroup': { 'combiner': 'AND', 'matchers': [{ 'keySelector': { 'trafficType': 'user', 'attribute': null }, 'matcherType': 'ALL_KEYS', 'negate': false, 'userDefinedSegmentMatcherData': null, 'whitelistMatcherData': null, 'unaryNumericMatcherData': null, 'betweenMatcherData': null }] }, 'partitions': [{ 'treatment': 'on', 'size': 0 }, { 'treatment': 'off', 'size': 100 }], 'label': 'in segment all' }] } as ISplit; +const ALWAYS_ON_SPLIT = { 'trafficTypeName': 'user', 'name': 'always-on', 'trafficAllocation': 100, 'trafficAllocationSeed': 1012950810, 'seed': -725161385, 'status': 'ACTIVE', 'killed': false, 'defaultTreatment': 'off', 'changeNumber': 1494364996459, 'algo': 2, 'conditions': [{ 'conditionType': 'ROLLOUT', 'matcherGroup': { 'combiner': 'AND', 'matchers': [{ 'keySelector': { 'trafficType': 'user', 'attribute': null }, 'matcherType': 'ALL_KEYS', 'negate': false, 'userDefinedSegmentMatcherData': null, 'whitelistMatcherData': null, 'unaryNumericMatcherData': null, 'betweenMatcherData': null }] }, 'partitions': [{ 'treatment': 'on', 'size': 100 }, { 'treatment': 'off', 'size': 0 }], 'label': 'in segment all' }], 'sets':[] } as ISplit; +const ALWAYS_OFF_SPLIT = { 'trafficTypeName': 'user', 'name': 'always-off', 'trafficAllocation': 100, 'trafficAllocationSeed': -331690370, 'seed': 403891040, 'status': 'ACTIVE', 'killed': false, 'defaultTreatment': 'on', 'changeNumber': 1494365020316, 'algo': 2, 'conditions': [{ 'conditionType': 'ROLLOUT', 'matcherGroup': { 'combiner': 'AND', 'matchers': [{ 'keySelector': { 'trafficType': 'user', 'attribute': null }, 'matcherType': 'ALL_KEYS', 'negate': false, 'userDefinedSegmentMatcherData': null, 'whitelistMatcherData': null, 'unaryNumericMatcherData': null, 'betweenMatcherData': null }] }, 'partitions': [{ 'treatment': 'on', 'size': 0 }, { 'treatment': 'off', 'size': 100 }], 'label': 'in segment all' }], 'sets':[] } as ISplit; const STORED_SPLITS: Record = { 'always-on': ALWAYS_ON_SPLIT, diff --git a/src/logger/constants.ts b/src/logger/constants.ts index 75f5fe1c..18c86f9f 100644 --- a/src/logger/constants.ts +++ b/src/logger/constants.ts @@ -91,12 +91,14 @@ export const WARN_NOT_EXISTENT_SPLIT = 215; export const WARN_LOWERCASE_TRAFFIC_TYPE = 216; export const WARN_NOT_EXISTENT_TT = 217; export const WARN_INTEGRATION_INVALID = 218; -export const WARN_SPLITS_FILTER_IGNORED = 219; export const WARN_SPLITS_FILTER_INVALID = 220; export const WARN_SPLITS_FILTER_EMPTY = 221; export const WARN_SDK_KEY = 222; export const STREAMING_PARSING_MY_SEGMENTS_UPDATE_V2 = 223; export const STREAMING_PARSING_SPLIT_UPDATE = 224; +export const WARN_SPLITS_FILTER_INVALID_SET = 225; +export const WARN_SPLITS_FILTER_LOWERCASE_SET = 226; +export const WARN_FLAGSET_NOT_CONFIGURED = 227; export const ERROR_ENGINE_COMBINER_IFELSEIF = 300; export const ERROR_LOGLEVEL_INVALID = 301; @@ -125,6 +127,8 @@ export const ERROR_LOCALHOST_MODULE_REQUIRED = 323; export const ERROR_STORAGE_INVALID = 324; export const ERROR_NOT_BOOLEAN = 325; export const ERROR_MIN_CONFIG_PARAM = 326; +export const ERROR_TOO_MANY_SETS = 327; +export const ERROR_SETS_FILTER_EXCLUSIVE = 328; // Log prefixes (a.k.a. tags or categories) export const LOG_PREFIX_SETTINGS = 'settings'; diff --git a/src/logger/messages/error.ts b/src/logger/messages/error.ts index 62691a72..a75dbccb 100644 --- a/src/logger/messages/error.ts +++ b/src/logger/messages/error.ts @@ -34,4 +34,6 @@ export const codesError: [number, string][] = [ [c.ERROR_LOCALHOST_MODULE_REQUIRED, c.LOG_PREFIX_SETTINGS + ': an invalid value was received for "sync.localhostMode" config. A valid entity should be provided for localhost mode.'], [c.ERROR_STORAGE_INVALID, c.LOG_PREFIX_SETTINGS+': the provided storage is invalid.%s Falling back into default MEMORY storage'], [c.ERROR_MIN_CONFIG_PARAM, c.LOG_PREFIX_SETTINGS + ': the provided "%s" config param is lower than allowed. Setting to the minimum value %s seconds'], + [c.ERROR_TOO_MANY_SETS, c.LOG_PREFIX_SETTINGS + ': the amount of flag sets provided are big causing uri length error.'], + [c.ERROR_SETS_FILTER_EXCLUSIVE, c.LOG_PREFIX_SETTINGS+': the Set filter is exclusive and cannot be used simultaneously with names or prefix filters. Ignoring names and prefixes.'], ]; diff --git a/src/logger/messages/warn.ts b/src/logger/messages/warn.ts index b19039f5..8c6f60ce 100644 --- a/src/logger/messages/warn.ts +++ b/src/logger/messages/warn.ts @@ -24,13 +24,15 @@ export const codesWarn: [number, string][] = codesError.concat([ [c.WARN_NOT_EXISTENT_SPLIT, '%s: feature flag "%s" does not exist in this environment. Please double check what feature flags exist in the Split user interface.'], [c.WARN_LOWERCASE_TRAFFIC_TYPE, '%s: traffic_type_name should be all lowercase - converting string to lowercase.'], [c.WARN_NOT_EXISTENT_TT, '%s: traffic type "%s" does not have any corresponding feature flag in this environment, make sure you\'re tracking your events to a valid traffic type defined in the Split user interface.'], + [c.WARN_FLAGSET_NOT_CONFIGURED, '%s: : you passed %s wich is not part of the configured FlagSetsFilter, ignoring Flag Set.'], // initialization / settings validation [c.WARN_INTEGRATION_INVALID, c.LOG_PREFIX_SETTINGS+': %s integration item(s) at settings is invalid. %s'], - [c.WARN_SPLITS_FILTER_IGNORED, c.LOG_PREFIX_SETTINGS+': feature flag filters have been configured but will have no effect if mode is not "%s", since synchronization is being deferred to an external tool.'], - [c.WARN_SPLITS_FILTER_INVALID, c.LOG_PREFIX_SETTINGS+': feature flag filter at position %s is invalid. It must be an object with a valid filter type ("byName" or "byPrefix") and a list of "values".'], + [c.WARN_SPLITS_FILTER_INVALID, c.LOG_PREFIX_SETTINGS+': feature flag filter at position %s is invalid. It must be an object with a valid filter type ("bySet", "byName" or "byPrefix") and a list of "values".'], [c.WARN_SPLITS_FILTER_EMPTY, c.LOG_PREFIX_SETTINGS+': feature flag filter configuration must be a non-empty array of filter objects.'], [c.WARN_SDK_KEY, c.LOG_PREFIX_SETTINGS+': You already have %s. We recommend keeping only one instance of the factory at all times (Singleton pattern) and reusing it throughout your application'], [c.STREAMING_PARSING_MY_SEGMENTS_UPDATE_V2, c.LOG_PREFIX_SYNC_STREAMING + 'Fetching MySegments due to an error processing %s notification: %s'], [c.STREAMING_PARSING_SPLIT_UPDATE, c.LOG_PREFIX_SYNC_STREAMING + 'Fetching SplitChanges due to an error processing SPLIT_UPDATE notification: %s'], + [c.WARN_SPLITS_FILTER_INVALID_SET, c.LOG_PREFIX_SETTINGS+': you passed %s, flag set must adhere to the regular expressions %s. This means a flag set must start with a letter or number, be in lowercase, alphanumeric and have a max length of 50 characters. %s was discarded.'], + [c.WARN_SPLITS_FILTER_LOWERCASE_SET, c.LOG_PREFIX_SETTINGS+': flag set %s should be all lowercase - converting string to lowercase.'], ]); diff --git a/src/readiness/readinessManager.ts b/src/readiness/readinessManager.ts index b4557dc9..c5ac1c35 100644 --- a/src/readiness/readinessManager.ts +++ b/src/readiness/readinessManager.ts @@ -112,7 +112,12 @@ export function readinessManagerFactory( return readinessManagerFactory(EventEmitter, readyTimeout, splits); }, + // @TODO review/remove next methods when non-recoverable errors are reworked + // Called on consumer mode, when storage fails to connect timeout, + // Called on 403 error (client-side SDK key on server-side), to set the SDK as destroyed for + // tracking and evaluations, while keeping event listeners to emit SDK_READY_TIMED_OUT event + setDestroyed() { isDestroyed = true; }, destroy() { isDestroyed = true; diff --git a/src/readiness/types.ts b/src/readiness/types.ts index 71c8b215..d126b5ec 100644 --- a/src/readiness/types.ts +++ b/src/readiness/types.ts @@ -55,6 +55,7 @@ export interface IReadinessManager { isOperational(): boolean, timeout(): void, + setDestroyed(): void, destroy(): void, /** for client-side */ diff --git a/src/sdkClient/__tests__/clientAttributesDecoration.spec.ts b/src/sdkClient/__tests__/clientAttributesDecoration.spec.ts index 45fe70ff..e007dc54 100644 --- a/src/sdkClient/__tests__/clientAttributesDecoration.spec.ts +++ b/src/sdkClient/__tests__/clientAttributesDecoration.spec.ts @@ -14,6 +14,22 @@ const clientMock = { }, getTreatmentsWithConfig(maybeKey: any, maybeFeatureFlagNames: string[], maybeAttributes?: any) { return maybeAttributes; + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getTreatmentsByFlagSets(maybeKey: any, maybeFeatureFlagNames: string[], maybeAttributes?: any, maybeFlagSetNames?: string[] | undefined) { + return maybeAttributes; + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getTreatmentsWithConfigByFlagSets(maybeKey: any, maybeFeatureFlagNames: string[], maybeAttributes?: any, maybeFlagSetNames?: string[] | undefined) { + return maybeAttributes; + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getTreatmentsByFlagSet(maybeKey: any, maybeFeatureFlagNames: string[], maybeAttributes?: any, maybeFlagSetName?: string | undefined) { + return maybeAttributes; + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getTreatmentsWithConfigByFlagSet(maybeKey: any, maybeFeatureFlagNames: string[], maybeAttributes?: any, maybeFlagSetName?: string | undefined) { + return maybeAttributes; } }; // @ts-expect-error @@ -225,4 +241,92 @@ describe('ATTRIBUTES DECORATION / evaluation', () => { }); + test('Evaluation attributes logic and precedence / getTreatmentsByFlagSets', () => { + + // If the same attribute is “cached” and provided on the function, the value received on the function call takes precedence. + expect(client.getTreatmentsByFlagSets('key', ['set'])).toEqual(undefined); // Nothing changes if no attributes were provided using the new api + expect(client.getTreatmentsByFlagSets('key', ['set'], { func_attr_bool: true, func_attr_str: 'true' })).toEqual({ func_attr_bool: true, func_attr_str: 'true' }); // Nothing changes if no attributes were provided using the new api + expect(client.getAttributes()).toEqual({}); // Attributes in memory storage must be empty + client.setAttribute('func_attr_bool', false); + expect(client.getAttributes()).toEqual({ 'func_attr_bool': false }); // In memory attribute storage must have the unique stored attribute + expect(client.getTreatmentsByFlagSets('key', ['set'], { func_attr_bool: true, func_attr_str: 'true' })).toEqual({ func_attr_bool: true, func_attr_str: 'true' }); // Function attributes has precedence against api ones + // @ts-ignore + expect(client.getTreatmentsByFlagSets('key', ['set'], null)).toEqual({ func_attr_bool: false }); // API attributes should be kept in memory and use for evaluations + expect(client.getTreatmentsByFlagSets('key', ['set'], { func_attr_str: 'true' })).toEqual({ func_attr_bool: false, func_attr_str: 'true' }); // API attributes should be kept in memory and use for evaluations + client.setAttributes({ func_attr_str: 'false' }); + expect(client.getAttributes()).toEqual({ 'func_attr_bool': false, 'func_attr_str': 'false' }); // In memory attribute storage must have two stored attributes + expect(client.getTreatmentsByFlagSets('key', ['set'], { func_attr_bool: true, func_attr_str: 'true', func_attr_number: 1 })).toEqual({ func_attr_bool: true, func_attr_str: 'true', func_attr_number: 1 }); // Function attributes has precedence against api ones + // @ts-ignore + expect(client.getTreatmentsByFlagSets('key', ['set'], null)).toEqual({ func_attr_bool: false, func_attr_str: 'false' }); // If the getTreatment function is called without attributes, stored attributes will be used to evaluate. + expect(client.getTreatmentsByFlagSets('key', ['set'])).toEqual({ func_attr_bool: false, func_attr_str: 'false' }); // If the getTreatment function is called without attributes, stored attributes will be used to evaluate. + client.clearAttributes(); + + }); + + test('Evaluation attributes logic and precedence / getTreatmentsWithConfigByFlagSets', () => { + + // If the same attribute is “cached” and provided on the function, the value received on the function call takes precedence. + expect(client.getTreatmentsWithConfigByFlagSets('key', ['set'])).toEqual(undefined); // Nothing changes if no attributes were provided using the new api + expect(client.getTreatmentsWithConfigByFlagSets('key', ['set'], { func_attr_bool: true, func_attr_str: 'true' })).toEqual({ func_attr_bool: true, func_attr_str: 'true' }); // Nothing changes if no attributes were provided using the new api + expect(client.getAttributes()).toEqual({}); // Attributes in memory storage must be empty + client.setAttribute('func_attr_bool', false); + expect(client.getAttributes()).toEqual({ 'func_attr_bool': false }); // In memory attribute storage must have the unique stored attribute + expect(client.getTreatmentsWithConfigByFlagSets('key', ['set'], { func_attr_bool: true, func_attr_str: 'true' })).toEqual({ func_attr_bool: true, func_attr_str: 'true' }); // Function attributes has precedence against api ones + // @ts-ignore + expect(client.getTreatmentsWithConfigByFlagSets('key', ['set'], null)).toEqual({ func_attr_bool: false }); // API attributes should be kept in memory and use for evaluations + expect(client.getTreatmentsWithConfigByFlagSets('key', ['set'], { func_attr_str: 'true' })).toEqual({ func_attr_bool: false, func_attr_str: 'true' }); // API attributes should be kept in memory and use for evaluations + client.setAttributes({ func_attr_str: 'false' }); + expect(client.getAttributes()).toEqual({ 'func_attr_bool': false, 'func_attr_str': 'false' }); // In memory attribute storage must have two stored attributes + expect(client.getTreatmentsWithConfigByFlagSets('key', ['set'], { func_attr_bool: true, func_attr_str: 'true', func_attr_number: 1 })).toEqual({ func_attr_bool: true, func_attr_str: 'true', func_attr_number: 1 }); // Function attributes has precedence against api ones + // @ts-ignore + expect(client.getTreatmentsWithConfigByFlagSets('key', ['set'], null)).toEqual({ func_attr_bool: false, func_attr_str: 'false' }); // If the getTreatment function is called without attributes, stored attributes will be used to evaluate. + expect(client.getTreatmentsWithConfigByFlagSets('key', ['set'])).toEqual({ func_attr_bool: false, func_attr_str: 'false' }); // If the getTreatment function is called without attributes, stored attributes will be used to evaluate. + client.clearAttributes(); + + }); + + test('Evaluation attributes logic and precedence / getTreatmentsByFlagSet', () => { + + // If the same attribute is “cached” and provided on the function, the value received on the function call takes precedence. + expect(client.getTreatmentsByFlagSet('key', 'set')).toEqual(undefined); // Nothing changes if no attributes were provided using the new api + expect(client.getTreatmentsByFlagSet('key', 'set', { func_attr_bool: true, func_attr_str: 'true' })).toEqual({ func_attr_bool: true, func_attr_str: 'true' }); // Nothing changes if no attributes were provided using the new api + expect(client.getAttributes()).toEqual({}); // Attributes in memory storage must be empty + client.setAttribute('func_attr_bool', false); + expect(client.getAttributes()).toEqual({ 'func_attr_bool': false }); // In memory attribute storage must have the unique stored attribute + expect(client.getTreatmentsByFlagSet('key', 'set', { func_attr_bool: true, func_attr_str: 'true' })).toEqual({ func_attr_bool: true, func_attr_str: 'true' }); // Function attributes has precedence against api ones + // @ts-ignore + expect(client.getTreatmentsByFlagSet('key', 'set', null)).toEqual({ func_attr_bool: false }); // API attributes should be kept in memory and use for evaluations + expect(client.getTreatmentsByFlagSet('key', 'set', { func_attr_str: 'true' })).toEqual({ func_attr_bool: false, func_attr_str: 'true' }); // API attributes should be kept in memory and use for evaluations + client.setAttributes({ func_attr_str: 'false' }); + expect(client.getAttributes()).toEqual({ 'func_attr_bool': false, 'func_attr_str': 'false' }); // In memory attribute storage must have two stored attributes + expect(client.getTreatmentsByFlagSet('key', 'set', { func_attr_bool: true, func_attr_str: 'true', func_attr_number: 1 })).toEqual({ func_attr_bool: true, func_attr_str: 'true', func_attr_number: 1 }); // Function attributes has precedence against api ones + // @ts-ignore + expect(client.getTreatmentsByFlagSet('key', 'set', null)).toEqual({ func_attr_bool: false, func_attr_str: 'false' }); // If the getTreatment function is called without attributes, stored attributes will be used to evaluate. + expect(client.getTreatmentsByFlagSet('key', 'set')).toEqual({ func_attr_bool: false, func_attr_str: 'false' }); // If the getTreatment function is called without attributes, stored attributes will be used to evaluate. + client.clearAttributes(); + + }); + + test('Evaluation attributes logic and precedence / getTreatmentsWithConfigByFlagSet', () => { + + // If the same attribute is “cached” and provided on the function, the value received on the function call takes precedence. + expect(client.getTreatmentsWithConfigByFlagSet('key', 'set')).toEqual(undefined); // Nothing changes if no attributes were provided using the new api + expect(client.getTreatmentsWithConfigByFlagSet('key', 'set', { func_attr_bool: true, func_attr_str: 'true' })).toEqual({ func_attr_bool: true, func_attr_str: 'true' }); // Nothing changes if no attributes were provided using the new api + expect(client.getAttributes()).toEqual({}); // Attributes in memory storage must be empty + client.setAttribute('func_attr_bool', false); + expect(client.getAttributes()).toEqual({ 'func_attr_bool': false }); // In memory attribute storage must have the unique stored attribute + expect(client.getTreatmentsWithConfigByFlagSet('key', 'set', { func_attr_bool: true, func_attr_str: 'true' })).toEqual({ func_attr_bool: true, func_attr_str: 'true' }); // Function attributes has precedence against api ones + // @ts-ignore + expect(client.getTreatmentsWithConfigByFlagSet('key', 'set', null)).toEqual({ func_attr_bool: false }); // API attributes should be kept in memory and use for evaluations + expect(client.getTreatmentsWithConfigByFlagSet('key', 'set', { func_attr_str: 'true' })).toEqual({ func_attr_bool: false, func_attr_str: 'true' }); // API attributes should be kept in memory and use for evaluations + client.setAttributes({ func_attr_str: 'false' }); + expect(client.getAttributes()).toEqual({ 'func_attr_bool': false, 'func_attr_str': 'false' }); // In memory attribute storage must have two stored attributes + expect(client.getTreatmentsWithConfigByFlagSet('key', 'set', { func_attr_bool: true, func_attr_str: 'true', func_attr_number: 1 })).toEqual({ func_attr_bool: true, func_attr_str: 'true', func_attr_number: 1 }); // Function attributes has precedence against api ones + // @ts-ignore + expect(client.getTreatmentsWithConfigByFlagSet('key', 'set', null)).toEqual({ func_attr_bool: false, func_attr_str: 'false' }); // If the getTreatment function is called without attributes, stored attributes will be used to evaluate. + expect(client.getTreatmentsWithConfigByFlagSet('key', 'set')).toEqual({ func_attr_bool: false, func_attr_str: 'false' }); // If the getTreatment function is called without attributes, stored attributes will be used to evaluate. + client.clearAttributes(); + + }); + }); diff --git a/src/sdkClient/__tests__/testUtils.ts b/src/sdkClient/__tests__/testUtils.ts index 30ee5966..901897e3 100644 --- a/src/sdkClient/__tests__/testUtils.ts +++ b/src/sdkClient/__tests__/testUtils.ts @@ -1,4 +1,4 @@ -const clientApiMethods = ['getTreatment', 'getTreatments', 'getTreatmentWithConfig', 'getTreatmentsWithConfig', 'track', 'destroy']; +const clientApiMethods = ['getTreatment', 'getTreatments', 'getTreatmentWithConfig', 'getTreatmentsWithConfig', 'getTreatmentsByFlagSets', 'getTreatmentsWithConfigByFlagSets', 'getTreatmentsByFlagSet', 'getTreatmentsWithConfigByFlagSet', 'track', 'destroy']; export function assertClientApi(client: any, sdkStatus?: object) { diff --git a/src/sdkClient/client.ts b/src/sdkClient/client.ts index 11036519..31aa5f74 100644 --- a/src/sdkClient/client.ts +++ b/src/sdkClient/client.ts @@ -1,15 +1,16 @@ -import { evaluateFeature, evaluateFeatures } from '../evaluator'; +import { evaluateFeature, evaluateFeatures, evaluateFeaturesByFlagSets } from '../evaluator'; import { thenable } from '../utils/promise/thenable'; import { getMatching, getBucketing } from '../utils/key'; import { validateSplitExistance } from '../utils/inputValidation/splitExistance'; import { validateTrafficTypeExistance } from '../utils/inputValidation/trafficTypeExistance'; import { SDK_NOT_READY } from '../utils/labels'; -import { CONTROL, TREATMENT, TREATMENTS, TREATMENT_WITH_CONFIG, TREATMENTS_WITH_CONFIG, TRACK } from '../utils/constants'; +import { CONTROL, TREATMENT, TREATMENTS, TREATMENT_WITH_CONFIG, TREATMENTS_WITH_CONFIG, TRACK, TREATMENTS_WITH_CONFIG_BY_FLAGSETS, TREATMENTS_BY_FLAGSETS, TREATMENTS_BY_FLAGSET, TREATMENTS_WITH_CONFIG_BY_FLAGSET } from '../utils/constants'; import { IEvaluationResult } from '../evaluator/types'; import { SplitIO, ImpressionDTO } from '../types'; import { IMPRESSION, IMPRESSION_QUEUEING } from '../logger/constants'; import { ISdkFactoryContext } from '../sdkFactory/types'; import { isStorageSync } from '../trackers/impressionObserver/utils'; +import { Method } from '../sync/submitters/types'; const treatmentNotReady = { treatment: CONTROL, label: SDK_NOT_READY }; @@ -81,6 +82,41 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl return getTreatments(key, featureFlagNames, attributes, true); } + function getTreatmentsByFlagSets(key: SplitIO.SplitKey, flagSetNames: string[], attributes: SplitIO.Attributes | undefined, withConfig = false, method: Method = TREATMENTS_BY_FLAGSETS) { + const stopTelemetryTracker = telemetryTracker.trackEval(method); + + const wrapUp = (evaluationResults: Record) => { + const queue: ImpressionDTO[] = []; + const treatments: Record = {}; + const evaluations = evaluationResults; + Object.keys(evaluations).forEach(featureFlagName => { + treatments[featureFlagName] = processEvaluation(evaluations[featureFlagName], featureFlagName, key, attributes, withConfig, `getTreatmentsByFlagSets${withConfig ? 'WithConfig' : ''}`, queue); + }); + impressionsTracker.track(queue, attributes); + + stopTelemetryTracker(queue[0] && queue[0].label); + return treatments; + }; + + const evaluations = readinessManager.isReady() || readinessManager.isReadyFromCache() ? + evaluateFeaturesByFlagSets(log, key, flagSetNames, attributes, storage) : + isStorageSync(settings) ? {} : Promise.resolve({}); // Promisify if async + + return thenable(evaluations) ? evaluations.then((res) => wrapUp(res)) : wrapUp(evaluations); + } + + function getTreatmentsWithConfigByFlagSets(key: SplitIO.SplitKey, flagSetNames: string[], attributes: SplitIO.Attributes | undefined) { + return getTreatmentsByFlagSets(key, flagSetNames, attributes, true, TREATMENTS_WITH_CONFIG_BY_FLAGSETS); + } + + function getTreatmentsByFlagSet(key: SplitIO.SplitKey, flagSetName: string, attributes: SplitIO.Attributes | undefined) { + return getTreatmentsByFlagSets(key, [flagSetName], attributes, false, TREATMENTS_BY_FLAGSET); + } + + function getTreatmentsWithConfigByFlagSet(key: SplitIO.SplitKey, flagSetName: string, attributes: SplitIO.Attributes | undefined) { + return getTreatmentsByFlagSets(key, [flagSetName], attributes, true, TREATMENTS_WITH_CONFIG_BY_FLAGSET); + } + // Internal function function processEvaluation( evaluation: IEvaluationResult, @@ -155,6 +191,10 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl getTreatmentWithConfig, getTreatments, getTreatmentsWithConfig, + getTreatmentsByFlagSets, + getTreatmentsWithConfigByFlagSets, + getTreatmentsByFlagSet, + getTreatmentsWithConfigByFlagSet, track, isClientSide: false } as SplitIO.IClient | SplitIO.IAsyncClient; diff --git a/src/sdkClient/clientAttributesDecoration.ts b/src/sdkClient/clientAttributesDecoration.ts index 6db04e3e..57413ad9 100644 --- a/src/sdkClient/clientAttributesDecoration.ts +++ b/src/sdkClient/clientAttributesDecoration.ts @@ -16,6 +16,10 @@ export function clientAttributesDecoration 0) && attributes !== false; return { valid, key, splitOrSplits, + flagSetOrFlagSets, attributes }; } @@ -99,6 +108,46 @@ export function clientInputValidationDecorator { + if (err.statusCode === 414) settings.log.error(ERROR_TOO_MANY_SETS); + throw err; + }); }, fetchSegmentChanges(since: number, segmentName: string, noCache?: boolean, till?: number) { diff --git a/src/storages/AbstractSplitsCacheAsync.ts b/src/storages/AbstractSplitsCacheAsync.ts index db127a97..bbd33e78 100644 --- a/src/storages/AbstractSplitsCacheAsync.ts +++ b/src/storages/AbstractSplitsCacheAsync.ts @@ -1,6 +1,7 @@ import { ISplitsCacheAsync } from './types'; import { ISplit } from '../dtos/types'; import { objectAssign } from '../utils/lang/objectAssign'; +import { ISet } from '../utils/lang/sets'; /** * This class provides a skeletal implementation of the ISplitsCacheAsync interface @@ -17,6 +18,7 @@ export abstract class AbstractSplitsCacheAsync implements ISplitsCacheAsync { abstract getChangeNumber(): Promise abstract getAll(): Promise abstract getSplitNames(): Promise + abstract getNamesByFlagSets(flagSets: string[]): Promise> abstract trafficTypeExists(trafficType: string): Promise abstract clear(): Promise diff --git a/src/storages/AbstractSplitsCacheSync.ts b/src/storages/AbstractSplitsCacheSync.ts index 5bc76665..92a29c18 100644 --- a/src/storages/AbstractSplitsCacheSync.ts +++ b/src/storages/AbstractSplitsCacheSync.ts @@ -1,6 +1,7 @@ import { ISplitsCacheSync } from './types'; import { ISplit } from '../dtos/types'; import { objectAssign } from '../utils/lang/objectAssign'; +import { ISet } from '../utils/lang/sets'; /** * This class provides a skeletal implementation of the ISplitsCacheSync interface @@ -78,6 +79,8 @@ export abstract class AbstractSplitsCacheSync implements ISplitsCacheSync { return false; } + abstract getNamesByFlagSets(flagSets: string[]): ISet + } /** diff --git a/src/storages/KeyBuilder.ts b/src/storages/KeyBuilder.ts index 3c54d403..c3124ae9 100644 --- a/src/storages/KeyBuilder.ts +++ b/src/storages/KeyBuilder.ts @@ -20,6 +20,10 @@ export class KeyBuilder { return `${this.prefix}.trafficType.${trafficType}`; } + buildFlagSetKey(flagSet: string) { + return `${this.prefix}.flagSet.${flagSet}`; + } + buildSplitKey(splitName: string) { return `${this.prefix}.split.${splitName}`; } diff --git a/src/storages/KeyBuilderSS.ts b/src/storages/KeyBuilderSS.ts index 526b2628..1b938320 100644 --- a/src/storages/KeyBuilderSS.ts +++ b/src/storages/KeyBuilderSS.ts @@ -7,6 +7,10 @@ export const METHOD_NAMES: Record = { ts: 'treatments', tc: 'treatmentWithConfig', tcs: 'treatmentsWithConfig', + tf: 'treatmentsByFlagSet', + tfs: 'treatmentsByFlagSets', + tcf: 'treatmentsWithConfigByFlagSet', + tcfs: 'treatmentsWithConfigByFlagSets', tr: 'track' }; diff --git a/src/storages/__tests__/KeyBuilder.spec.ts b/src/storages/__tests__/KeyBuilder.spec.ts index 8103ef41..01a09efa 100644 --- a/src/storages/__tests__/KeyBuilder.spec.ts +++ b/src/storages/__tests__/KeyBuilder.spec.ts @@ -67,6 +67,17 @@ test('KEYS / traffic type keys', () => { }); +test('KEYS / flag set keys', () => { + const prefix = 'unit_test.SPLITIO'; + const builder = new KeyBuilder(prefix); + + const flagSetName = 'flagset_x'; + const expectedKey = `${prefix}.flagSet.${flagSetName}`; + + expect(builder.buildFlagSetKey(flagSetName)).toBe(expectedKey); + +}); + test('KEYS / impressions', () => { const builder = new KeyBuilderSS(prefix, metadata); diff --git a/src/storages/__tests__/testUtils.ts b/src/storages/__tests__/testUtils.ts index 2f8cb245..94e11c36 100644 --- a/src/storages/__tests__/testUtils.ts +++ b/src/storages/__tests__/testUtils.ts @@ -32,3 +32,16 @@ export const splitWithAccountTTAndUsesSegments: ISplit = { trafficTypeName: 'acc export const something: ISplit = { name: 'something' }; //@ts-ignore export const somethingElse: ISplit = { name: 'something else' }; + +// - With flag sets + +//@ts-ignore +export const featureFlagWithEmptyFS: ISplit = { name: 'ff_empty', sets: [] }; +//@ts-ignore +export const featureFlagOne: ISplit = { name: 'ff_one', sets: ['o','n','e'] }; +//@ts-ignore +export const featureFlagTwo: ISplit = { name: 'ff_two', sets: ['t','w','o'] }; +//@ts-ignore +export const featureFlagThree: ISplit = { name: 'ff_three', sets: ['t','h','r','e'] }; +//@ts-ignore +export const featureFlagWithoutFS: ISplit = { name: 'ff_four' }; diff --git a/src/storages/inLocalStorage/SplitsCacheInLocal.ts b/src/storages/inLocalStorage/SplitsCacheInLocal.ts index 44f4664c..9e02ee15 100644 --- a/src/storages/inLocalStorage/SplitsCacheInLocal.ts +++ b/src/storages/inLocalStorage/SplitsCacheInLocal.ts @@ -4,6 +4,7 @@ import { isFiniteNumber, toNumber, isNaNNumber } from '../../utils/lang'; import { KeyBuilderCS } from '../KeyBuilderCS'; import { ILogger } from '../../logger/types'; import { LOG_PREFIX } from './constants'; +import { ISet, _Set, returnSetsUnion, setToArray } from '../../utils/lang/sets'; /** * ISplitsCacheSync implementation that stores split definitions in browser LocalStorage. @@ -12,8 +13,8 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { private readonly keys: KeyBuilderCS; private readonly splitFiltersValidation: ISplitFiltersValidation; + private readonly flagSetsFilter: string[]; private hasSync?: boolean; - private cacheReadyButNeedsToFlush: boolean = false; private updateNewFilter?: boolean; /** @@ -21,10 +22,11 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { * @param {number | undefined} expirationTimestamp * @param {ISplitFiltersValidation} splitFiltersValidation */ - constructor(private readonly log: ILogger, keys: KeyBuilderCS, expirationTimestamp?: number, splitFiltersValidation: ISplitFiltersValidation = { queryString: null, groupedFilters: { byName: [], byPrefix: [] }, validFilters: [] }) { + constructor(private readonly log: ILogger, keys: KeyBuilderCS, expirationTimestamp?: number, splitFiltersValidation: ISplitFiltersValidation = { queryString: null, groupedFilters: { bySet: [], byName: [], byPrefix: [] }, validFilters: [] }) { super(); this.keys = keys; this.splitFiltersValidation = splitFiltersValidation; + this.flagSetsFilter = this.splitFiltersValidation.groupedFilters.bySet; this._checkExpiration(expirationTimestamp); @@ -106,6 +108,9 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { this._incrementCounts(split); this._decrementCounts(previousSplit); + if (previousSplit) this.removeFromFlagSets(previousSplit.name, previousSplit.sets); + this.addToFlagSets(split); + return true; } catch (e) { this.log.error(LOG_PREFIX + e); @@ -119,6 +124,7 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { localStorage.removeItem(this.keys.buildSplitKey(name)); this._decrementCounts(split); + if (split) this.removeFromFlagSets(split.name, split.sets); return true; } catch (e) { @@ -133,11 +139,6 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { } setChangeNumber(changeNumber: number): boolean { - // when cache is ready but using a new split query, we must clear all split data - if (this.cacheReadyButNeedsToFlush) { - this.clear(); - this.cacheReadyButNeedsToFlush = false; - } // when using a new split query, we must update it at the store if (this.updateNewFilter) { @@ -220,7 +221,7 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { * @override */ checkCache(): boolean { - return this.getChangeNumber() > -1 || this.cacheReadyButNeedsToFlush; + return this.getChangeNumber() > -1; } /** @@ -237,7 +238,7 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { } private _checkFilterQuery() { - const { queryString, groupedFilters } = this.splitFiltersValidation; + const { queryString } = this.splitFiltersValidation; const queryKey = this.keys.buildSplitsFilterQueryKey(); const currentQueryString = localStorage.getItem(queryKey); @@ -246,29 +247,74 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync { // mark cache to update the new query filter on first successful splits fetch this.updateNewFilter = true; - // if cache is ready: - if (this.checkCache()) { - // * set change number to -1, to fetch splits with -1 `since` value. - localStorage.setItem(this.keys.buildSplitsTillKey(), '-1'); - - // * remove from cache splits that doesn't match with the new filters - this.getSplitNames().forEach((splitName) => { - if (queryString && ( - // @TODO consider redefining `groupedFilters` to expose a method like `groupedFilters::filter(splitName): boolean` - groupedFilters.byName.indexOf(splitName) > -1 || - groupedFilters.byPrefix.some((prefix: string) => splitName.startsWith(prefix + '__')) - )) { - // * set `cacheReadyButNeedsToFlush` so that `checkCache` returns true (the storage is ready to be used) and the data is cleared before updating on first successful splits fetch - this.cacheReadyButNeedsToFlush = true; - return; - } - this.removeSplit(splitName); - }); - } + // if there is cache, clear it + if (this.checkCache()) this.clear(); + } catch (e) { this.log.error(LOG_PREFIX + e); } } // if the filter didn't change, nothing is done } + + getNamesByFlagSets(flagSets: string[]): ISet{ + let toReturn: ISet = new _Set([]); + flagSets.forEach(flagSet => { + const flagSetKey = this.keys.buildFlagSetKey(flagSet); + let flagSetFromLocalStorage = localStorage.getItem(flagSetKey); + + if (flagSetFromLocalStorage) { + const flagSetCache = new _Set(JSON.parse(flagSetFromLocalStorage)); + toReturn = returnSetsUnion(toReturn, flagSetCache); + } + }); + return toReturn; + + } + + private addToFlagSets(featureFlag: ISplit) { + if (!featureFlag.sets) return; + + featureFlag.sets.forEach(featureFlagSet => { + + if (this.flagSetsFilter.length > 0 && !this.flagSetsFilter.some(filterFlagSet => filterFlagSet === featureFlagSet)) return; + + const flagSetKey = this.keys.buildFlagSetKey(featureFlagSet); + + let flagSetFromLocalStorage = localStorage.getItem(flagSetKey); + if (!flagSetFromLocalStorage) flagSetFromLocalStorage = '[]'; + + const flagSetCache = new _Set(JSON.parse(flagSetFromLocalStorage)); + flagSetCache.add(featureFlag.name); + + localStorage.setItem(flagSetKey, JSON.stringify(setToArray(flagSetCache))); + }); + } + + private removeFromFlagSets(featureFlagName: string, flagSets?: string[]) { + if (!flagSets) return; + + flagSets.forEach(flagSet => { + this.removeNames(flagSet, featureFlagName); + }); + } + + private removeNames(flagSetName: string, featureFlagName: string) { + const flagSetKey = this.keys.buildFlagSetKey(flagSetName); + + let flagSetFromLocalStorage = localStorage.getItem(flagSetKey); + + if (!flagSetFromLocalStorage) return; + + const flagSetCache = new _Set(JSON.parse(flagSetFromLocalStorage)); + flagSetCache.delete(featureFlagName); + + if (flagSetCache.size === 0) { + localStorage.removeItem(flagSetKey); + return; + } + + localStorage.setItem(flagSetKey, JSON.stringify(setToArray(flagSetCache))); + } + } diff --git a/src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts b/src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts index 782b2f7f..b40f5f8a 100644 --- a/src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts +++ b/src/storages/inLocalStorage/__tests__/SplitsCacheInLocal.spec.ts @@ -1,8 +1,9 @@ import { SplitsCacheInLocal } from '../SplitsCacheInLocal'; import { KeyBuilderCS } from '../../KeyBuilderCS'; import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; -import { splitWithUserTT, splitWithAccountTT, splitWithAccountTTAndUsesSegments, something, somethingElse } from '../../__tests__/testUtils'; +import { splitWithUserTT, splitWithAccountTT, splitWithAccountTTAndUsesSegments, something, somethingElse, featureFlagOne, featureFlagTwo, featureFlagThree, featureFlagWithEmptyFS, featureFlagWithoutFS } from '../../__tests__/testUtils'; import { ISplit } from '../../../dtos/types'; +import { _Set } from '../../../utils/lang/sets'; test('SPLIT CACHE / LocalStorage', () => { const cache = new SplitsCacheInLocal(loggerMock, new KeyBuilderCS('SPLITIO', 'user')); @@ -160,3 +161,63 @@ test('SPLIT CACHE / LocalStorage / usesSegments', () => { cache.removeSplit('split4'); expect(cache.usesSegments()).toBe(false); // 0 splits using segments }); + +test('SPLIT CACHE / LocalStorage / flag set cache tests', () => { + // @ts-ignore + const cache = new SplitsCacheInLocal(loggerMock, new KeyBuilderCS('SPLITIO', 'user'), undefined, { groupedFilters: { bySet: ['o', 'n', 'e', 'x'] } }); + const emptySet = new _Set([]); + + cache.addSplits([ + [featureFlagOne.name, featureFlagOne], + [featureFlagTwo.name, featureFlagTwo], + [featureFlagThree.name, featureFlagThree], + ]); + cache.addSplit(featureFlagWithEmptyFS.name, featureFlagWithEmptyFS); + + expect(cache.getNamesByFlagSets(['o'])).toEqual(new _Set(['ff_one', 'ff_two'])); + expect(cache.getNamesByFlagSets(['n'])).toEqual(new _Set(['ff_one'])); + expect(cache.getNamesByFlagSets(['e'])).toEqual(new _Set(['ff_one','ff_three'])); + expect(cache.getNamesByFlagSets(['t'])).toEqual(emptySet); // 't' not in filter + expect(cache.getNamesByFlagSets(['o','n','e'])).toEqual(new _Set(['ff_one','ff_two','ff_three'])); + + cache.addSplit(featureFlagOne.name, {...featureFlagOne, sets: ['1']}); + + expect(cache.getNamesByFlagSets(['1'])).toEqual(emptySet); // '1' not in filter + expect(cache.getNamesByFlagSets(['o'])).toEqual(new _Set(['ff_two'])); + expect(cache.getNamesByFlagSets(['n'])).toEqual(emptySet); + + cache.addSplit(featureFlagOne.name, {...featureFlagOne, sets: ['x']}); + expect(cache.getNamesByFlagSets(['x'])).toEqual(new _Set(['ff_one'])); + expect(cache.getNamesByFlagSets(['o','e','x'])).toEqual(new _Set(['ff_one','ff_two','ff_three'])); + + + cache.removeSplit(featureFlagOne.name); + expect(cache.getNamesByFlagSets(['x'])).toEqual(emptySet); + + cache.removeSplit(featureFlagOne.name); + expect(cache.getNamesByFlagSets(['y'])).toEqual(emptySet); // 'y' not in filter + expect(cache.getNamesByFlagSets([])).toEqual(emptySet); + + cache.addSplit(featureFlagWithEmptyFS.name, featureFlagWithoutFS); + expect(cache.getNamesByFlagSets([])).toEqual(emptySet); +}); + +// if FlagSets are not defined, it should store all FlagSets in memory. +test('SPLIT CACHE / LocalStorage / flag set cache tests without filters', () => { + const cacheWithoutFilters = new SplitsCacheInLocal(loggerMock, new KeyBuilderCS('SPLITIO', 'user')); + const emptySet = new _Set([]); + + cacheWithoutFilters.addSplits([ + [featureFlagOne.name, featureFlagOne], + [featureFlagTwo.name, featureFlagTwo], + [featureFlagThree.name, featureFlagThree], + ]); + cacheWithoutFilters.addSplit(featureFlagWithEmptyFS.name, featureFlagWithEmptyFS); + + expect(cacheWithoutFilters.getNamesByFlagSets(['o'])).toEqual(new _Set(['ff_one', 'ff_two'])); + expect(cacheWithoutFilters.getNamesByFlagSets(['n'])).toEqual(new _Set(['ff_one'])); + expect(cacheWithoutFilters.getNamesByFlagSets(['e'])).toEqual(new _Set(['ff_one','ff_three'])); + expect(cacheWithoutFilters.getNamesByFlagSets(['t'])).toEqual(new _Set(['ff_two','ff_three'])); + expect(cacheWithoutFilters.getNamesByFlagSets(['y'])).toEqual(emptySet); + expect(cacheWithoutFilters.getNamesByFlagSets(['o','n','e'])).toEqual(new _Set(['ff_one','ff_two','ff_three'])); +}); diff --git a/src/storages/inLocalStorage/index.ts b/src/storages/inLocalStorage/index.ts index 59eefae5..79bf1946 100644 --- a/src/storages/inLocalStorage/index.ts +++ b/src/storages/inLocalStorage/index.ts @@ -54,7 +54,7 @@ export function InLocalStorage(options: InLocalStorageOptions = {}): IStorageSyn uniqueKeys: impressionsMode === NONE ? new UniqueKeysCacheInMemoryCS() : undefined, destroy() { - this.splits = new SplitsCacheInMemory(); + this.splits = new SplitsCacheInMemory(__splitFiltersValidation); this.segments = new MySegmentsCacheInMemory(); this.impressions.clear(); this.impressionCounts && this.impressionCounts.clear(); @@ -75,7 +75,7 @@ export function InLocalStorage(options: InLocalStorageOptions = {}): IStorageSyn telemetry: this.telemetry, destroy() { - this.splits = new SplitsCacheInMemory(); + this.splits = new SplitsCacheInMemory(__splitFiltersValidation); this.segments = new MySegmentsCacheInMemory(); } }; diff --git a/src/storages/inMemory/InMemoryStorage.ts b/src/storages/inMemory/InMemoryStorage.ts index fbc6711d..ccf3bd6a 100644 --- a/src/storages/inMemory/InMemoryStorage.ts +++ b/src/storages/inMemory/InMemoryStorage.ts @@ -14,9 +14,9 @@ import { UniqueKeysCacheInMemory } from './UniqueKeysCacheInMemory'; * @param params parameters required by EventsCacheSync */ export function InMemoryStorageFactory(params: IStorageFactoryParams): IStorageSync { - const { settings: { scheduler: { impressionsQueueSize, eventsQueueSize, }, sync: { impressionsMode } } } = params; + const { settings: { scheduler: { impressionsQueueSize, eventsQueueSize, }, sync: { impressionsMode, __splitFiltersValidation } } } = params; - const splits = new SplitsCacheInMemory(); + const splits = new SplitsCacheInMemory(__splitFiltersValidation); const segments = new SegmentsCacheInMemory(); const storage = { diff --git a/src/storages/inMemory/InMemoryStorageCS.ts b/src/storages/inMemory/InMemoryStorageCS.ts index 2fd31203..84d2351b 100644 --- a/src/storages/inMemory/InMemoryStorageCS.ts +++ b/src/storages/inMemory/InMemoryStorageCS.ts @@ -14,9 +14,9 @@ import { UniqueKeysCacheInMemoryCS } from './UniqueKeysCacheInMemoryCS'; * @param params parameters required by EventsCacheSync */ export function InMemoryStorageCSFactory(params: IStorageFactoryParams): IStorageSync { - const { settings: { scheduler: { impressionsQueueSize, eventsQueueSize, }, sync: { impressionsMode } } } = params; + const { settings: { scheduler: { impressionsQueueSize, eventsQueueSize, }, sync: { impressionsMode, __splitFiltersValidation } } } = params; - const splits = new SplitsCacheInMemory(); + const splits = new SplitsCacheInMemory(__splitFiltersValidation); const segments = new MySegmentsCacheInMemory(); const storage = { @@ -50,7 +50,7 @@ export function InMemoryStorageCSFactory(params: IStorageFactoryParams): IStorag // Set a new splits cache to clean it for the client without affecting other clients destroy() { - this.splits = new SplitsCacheInMemory(); + this.splits = new SplitsCacheInMemory(__splitFiltersValidation); this.segments.clear(); } }; diff --git a/src/storages/inMemory/SplitsCacheInMemory.ts b/src/storages/inMemory/SplitsCacheInMemory.ts index ef0d9dcf..8cb45aef 100644 --- a/src/storages/inMemory/SplitsCacheInMemory.ts +++ b/src/storages/inMemory/SplitsCacheInMemory.ts @@ -1,6 +1,7 @@ -import { ISplit } from '../../dtos/types'; +import { ISplit, ISplitFiltersValidation } from '../../dtos/types'; import { AbstractSplitsCacheSync, usesSegments } from '../AbstractSplitsCacheSync'; import { isFiniteNumber } from '../../utils/lang'; +import { ISet, _Set, returnSetsUnion } from '../../utils/lang/sets'; /** * Default ISplitsCacheSync implementation that stores split definitions in memory. @@ -8,10 +9,17 @@ import { isFiniteNumber } from '../../utils/lang'; */ export class SplitsCacheInMemory extends AbstractSplitsCacheSync { + private flagSetsFilter: string[]; private splitsCache: Record = {}; private ttCache: Record = {}; private changeNumber: number = -1; private splitsWithSegmentsCount: number = 0; + private flagSetsCache: Record> = {}; + + constructor(splitFiltersValidation: ISplitFiltersValidation = { queryString: null, groupedFilters: { bySet: [], byName: [], byPrefix: [] }, validFilters: [] }) { + super(); + this.flagSetsFilter = splitFiltersValidation.groupedFilters.bySet; + } clear() { this.splitsCache = {}; @@ -28,6 +36,8 @@ export class SplitsCacheInMemory extends AbstractSplitsCacheSync { this.ttCache[previousTtName]--; if (!this.ttCache[previousTtName]) delete this.ttCache[previousTtName]; + this.removeFromFlagSets(previousSplit.name, previousSplit.sets); + if (usesSegments(previousSplit)) { // Substract from segments count for the previous version of this Split. this.splitsWithSegmentsCount--; } @@ -39,6 +49,7 @@ export class SplitsCacheInMemory extends AbstractSplitsCacheSync { // Update TT cache const ttName = split.trafficTypeName; this.ttCache[ttName] = (this.ttCache[ttName] || 0) + 1; + this.addToFlagSets(split); // Add to segments count for the new version of the Split if (usesSegments(split)) this.splitsWithSegmentsCount++; @@ -58,6 +69,7 @@ export class SplitsCacheInMemory extends AbstractSplitsCacheSync { const ttName = split.trafficTypeName; this.ttCache[ttName]--; // Update tt cache if (!this.ttCache[ttName]) delete this.ttCache[ttName]; + this.removeFromFlagSets(split.name, split.sets); // Update the segments count. if (usesSegments(split)) this.splitsWithSegmentsCount--; @@ -93,4 +105,41 @@ export class SplitsCacheInMemory extends AbstractSplitsCacheSync { return this.getChangeNumber() === -1 || this.splitsWithSegmentsCount > 0; } + getNamesByFlagSets(flagSets: string[]): ISet{ + let toReturn: ISet = new _Set([]); + flagSets.forEach(flagSet => { + const featureFlagNames = this.flagSetsCache[flagSet]; + if (featureFlagNames) { + toReturn = returnSetsUnion(toReturn, featureFlagNames); + } + }); + return toReturn; + + } + + private addToFlagSets(featureFlag: ISplit) { + if (!featureFlag.sets) return; + featureFlag.sets.forEach(featureFlagSet => { + + if (this.flagSetsFilter.length > 0 && !this.flagSetsFilter.some(filterFlagSet => filterFlagSet === featureFlagSet)) return; + + if (!this.flagSetsCache[featureFlagSet]) this.flagSetsCache[featureFlagSet] = new _Set([]); + + this.flagSetsCache[featureFlagSet].add(featureFlag.name); + }); + } + + private removeFromFlagSets(featureFlagName :string, flagSets: string[] | undefined) { + if (!flagSets) return; + flagSets.forEach(flagSet => { + this.removeNames(flagSet, featureFlagName); + }); + } + + private removeNames(flagSetName: string, featureFlagName: string) { + if (!this.flagSetsCache[flagSetName]) return; + this.flagSetsCache[flagSetName].delete(featureFlagName); + if (this.flagSetsCache[flagSetName].size === 0) delete this.flagSetsCache[flagSetName]; + } + } diff --git a/src/storages/inMemory/__tests__/SplitsCacheInMemory.spec.ts b/src/storages/inMemory/__tests__/SplitsCacheInMemory.spec.ts index 979906df..865705cf 100644 --- a/src/storages/inMemory/__tests__/SplitsCacheInMemory.spec.ts +++ b/src/storages/inMemory/__tests__/SplitsCacheInMemory.spec.ts @@ -1,6 +1,7 @@ import { SplitsCacheInMemory } from '../SplitsCacheInMemory'; import { ISplit } from '../../../dtos/types'; -import { splitWithUserTT, splitWithAccountTT, something, somethingElse } from '../../__tests__/testUtils'; +import { splitWithUserTT, splitWithAccountTT, something, somethingElse, featureFlagWithEmptyFS, featureFlagWithoutFS, featureFlagOne, featureFlagTwo, featureFlagThree } from '../../__tests__/testUtils'; +import { _Set } from '../../../utils/lang/sets'; test('SPLITS CACHE / In Memory', () => { const cache = new SplitsCacheInMemory(); @@ -113,3 +114,63 @@ test('SPLITS CACHE / In Memory / killLocally', () => { expect(lol1Split.defaultTreatment).not.toBe('some_treatment_2'); // existing split is not updated if given changeNumber is older }); + +test('SPLITS CACHE / In Memory / flag set cache tests', () => { + // @ts-ignore + const cache = new SplitsCacheInMemory({ groupedFilters: { bySet: ['o', 'n', 'e', 'x'] } }); + const emptySet = new _Set([]); + + cache.addSplits([ + [featureFlagOne.name, featureFlagOne], + [featureFlagTwo.name, featureFlagTwo], + [featureFlagThree.name, featureFlagThree], + ]); + cache.addSplit(featureFlagWithEmptyFS.name, featureFlagWithEmptyFS); + + expect(cache.getNamesByFlagSets(['o'])).toEqual(new _Set(['ff_one', 'ff_two'])); + expect(cache.getNamesByFlagSets(['n'])).toEqual(new _Set(['ff_one'])); + expect(cache.getNamesByFlagSets(['e'])).toEqual(new _Set(['ff_one','ff_three'])); + expect(cache.getNamesByFlagSets(['t'])).toEqual(emptySet); // 't' not in filter + expect(cache.getNamesByFlagSets(['o','n','e'])).toEqual(new _Set(['ff_one','ff_two','ff_three'])); + + cache.addSplit(featureFlagOne.name, {...featureFlagOne, sets: ['1']}); + + expect(cache.getNamesByFlagSets(['1'])).toEqual(emptySet); // '1' not in filter + expect(cache.getNamesByFlagSets(['o'])).toEqual(new _Set(['ff_two'])); + expect(cache.getNamesByFlagSets(['n'])).toEqual(emptySet); + + cache.addSplit(featureFlagOne.name, {...featureFlagOne, sets: ['x']}); + expect(cache.getNamesByFlagSets(['x'])).toEqual(new _Set(['ff_one'])); + expect(cache.getNamesByFlagSets(['o','e','x'])).toEqual(new _Set(['ff_one','ff_two','ff_three'])); + + + cache.removeSplit(featureFlagOne.name); + expect(cache.getNamesByFlagSets(['x'])).toEqual(emptySet); + + cache.removeSplit(featureFlagOne.name); + expect(cache.getNamesByFlagSets(['y'])).toEqual(emptySet); // 'y' not in filter + expect(cache.getNamesByFlagSets([])).toEqual(emptySet); + + cache.addSplit(featureFlagWithEmptyFS.name, featureFlagWithoutFS); + expect(cache.getNamesByFlagSets([])).toEqual(emptySet); +}); + +// if FlagSets are not defined, it should store all FlagSets in memory. +test('SPLIT CACHE / LocalStorage / flag set cache tests without filters', () => { + const cacheWithoutFilters = new SplitsCacheInMemory(); + const emptySet = new _Set([]); + + cacheWithoutFilters.addSplits([ + [featureFlagOne.name, featureFlagOne], + [featureFlagTwo.name, featureFlagTwo], + [featureFlagThree.name, featureFlagThree], + ]); + cacheWithoutFilters.addSplit(featureFlagWithEmptyFS.name, featureFlagWithEmptyFS); + + expect(cacheWithoutFilters.getNamesByFlagSets(['o'])).toEqual(new _Set(['ff_one', 'ff_two'])); + expect(cacheWithoutFilters.getNamesByFlagSets(['n'])).toEqual(new _Set(['ff_one'])); + expect(cacheWithoutFilters.getNamesByFlagSets(['e'])).toEqual(new _Set(['ff_one','ff_three'])); + expect(cacheWithoutFilters.getNamesByFlagSets(['t'])).toEqual(new _Set(['ff_two','ff_three'])); + expect(cacheWithoutFilters.getNamesByFlagSets(['y'])).toEqual(emptySet); + expect(cacheWithoutFilters.getNamesByFlagSets(['o','n','e'])).toEqual(new _Set(['ff_one','ff_two','ff_three'])); +}); diff --git a/src/storages/inRedis/SplitsCacheInRedis.ts b/src/storages/inRedis/SplitsCacheInRedis.ts index 43f86481..f58f49ab 100644 --- a/src/storages/inRedis/SplitsCacheInRedis.ts +++ b/src/storages/inRedis/SplitsCacheInRedis.ts @@ -5,6 +5,7 @@ import { ILogger } from '../../logger/types'; import { LOG_PREFIX } from './constants'; import { ISplit } from '../../dtos/types'; import { AbstractSplitsCacheAsync } from '../AbstractSplitsCacheAsync'; +import { ISet } from '../../utils/lang/sets'; /** * Discard errors for an answer of multiple operations. @@ -188,6 +189,17 @@ export class SplitsCacheInRedis extends AbstractSplitsCacheAsync { ); } + /** + * Get list of split names related to a given flag set names list. + * The returned promise is resolved with the list of split names, + * or rejected if wrapper operation fails. + * @todo this is a no-op method to be implemented + */ + getNamesByFlagSets(): Promise> { + this.log.error(LOG_PREFIX + 'ByFlagSet/s evaluations are not supported with Redis storage yet.'); + return Promise.reject(); + } + /** * Check traffic type existence. * The returned promise is resolved with a boolean indicating whether the TT exist or not. diff --git a/src/storages/pluggable/SplitsCachePluggable.ts b/src/storages/pluggable/SplitsCachePluggable.ts index 47421987..786fb8a5 100644 --- a/src/storages/pluggable/SplitsCachePluggable.ts +++ b/src/storages/pluggable/SplitsCachePluggable.ts @@ -5,6 +5,7 @@ import { ILogger } from '../../logger/types'; import { ISplit } from '../../dtos/types'; import { LOG_PREFIX } from './constants'; import { AbstractSplitsCacheAsync } from '../AbstractSplitsCacheAsync'; +import { ISet } from '../../utils/lang/sets'; /** * ISplitsCacheAsync implementation for pluggable storages. @@ -154,6 +155,17 @@ export class SplitsCachePluggable extends AbstractSplitsCacheAsync { ); } + /** + * Get list of split names related to a given flag set names list. + * The returned promise is resolved with the list of split names, + * or rejected if wrapper operation fails. + * @todo this is a no-op method to be implemented + */ + getNamesByFlagSets(): Promise> { + this.log.error(LOG_PREFIX + 'ByFlagSet/s evaluations are not supported with pluggable storage yet.'); + return Promise.reject(); + } + /** * Check traffic type existence. * The returned promise is resolved with a boolean indicating whether the TT exist or not. diff --git a/src/storages/types.ts b/src/storages/types.ts index dc2dfbc2..30832f5e 100644 --- a/src/storages/types.ts +++ b/src/storages/types.ts @@ -1,6 +1,7 @@ import { MaybeThenable, ISplit } from '../dtos/types'; import { EventDataType, HttpErrors, HttpLatencies, ImpressionDataType, LastSync, Method, MethodExceptions, MethodLatencies, MultiMethodExceptions, MultiMethodLatencies, MultiConfigs, OperationType, StoredEventWithMetadata, StoredImpressionWithMetadata, StreamingEvent, UniqueKeysPayloadCs, UniqueKeysPayloadSs, TelemetryUsageStatsPayload, UpdatesFromSSEEnum } from '../sync/submitters/types'; import { SplitIO, ImpressionDTO, ISettings } from '../types'; +import { ISet } from '../utils/lang/sets'; /** * Interface of a pluggable storage wrapper. @@ -208,7 +209,8 @@ export interface ISplitsCacheBase { clear(): MaybeThenable, // should never reject or throw an exception. Instead return false by default, to avoid emitting SDK_READY_FROM_CACHE. checkCache(): MaybeThenable, - killLocally(name: string, defaultTreatment: string, changeNumber: number): MaybeThenable + killLocally(name: string, defaultTreatment: string, changeNumber: number): MaybeThenable, + getNamesByFlagSets(flagSets: string[]): MaybeThenable> } export interface ISplitsCacheSync extends ISplitsCacheBase { @@ -224,7 +226,8 @@ export interface ISplitsCacheSync extends ISplitsCacheBase { usesSegments(): boolean, clear(): void, checkCache(): boolean, - killLocally(name: string, defaultTreatment: string, changeNumber: number): boolean + killLocally(name: string, defaultTreatment: string, changeNumber: number): boolean, + getNamesByFlagSets(flagSets: string[]): ISet } export interface ISplitsCacheAsync extends ISplitsCacheBase { @@ -240,7 +243,8 @@ export interface ISplitsCacheAsync extends ISplitsCacheBase { usesSegments(): Promise, clear(): Promise, checkCache(): Promise, - killLocally(name: string, defaultTreatment: string, changeNumber: number): Promise + killLocally(name: string, defaultTreatment: string, changeNumber: number): Promise, + getNamesByFlagSets(flagSets: string[]): Promise> } /** Segments cache */ diff --git a/src/sync/polling/syncTasks/splitsSyncTask.ts b/src/sync/polling/syncTasks/splitsSyncTask.ts index a4568d6d..baef383c 100644 --- a/src/sync/polling/syncTasks/splitsSyncTask.ts +++ b/src/sync/polling/syncTasks/splitsSyncTask.ts @@ -24,6 +24,7 @@ export function splitsSyncTaskFactory( splitChangesFetcherFactory(fetchSplitChanges), storage.splits, storage.segments, + settings.sync.__splitFiltersValidation, readiness.splits, settings.startup.requestTimeoutBeforeReady, settings.startup.retriesOnFailureBeforeReady, diff --git a/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts b/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts index c38e569d..54dec32b 100644 --- a/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts +++ b/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts @@ -13,6 +13,8 @@ import { loggerMock } from '../../../../logger/__tests__/sdkLogger.mock'; import { telemetryTrackerFactory } from '../../../../trackers/telemetryTracker'; import { splitNotifications } from '../../../streaming/__tests__/dataMocks'; +const ARCHIVED_FF = 'ARCHIVED'; + const activeSplitWithSegments = { name: 'Split1', status: 'ACTIVE', @@ -38,6 +40,48 @@ const archivedSplit = { name: 'Split2', status: 'ARCHIVED' }; +// @ts-ignore +const testFFSetsAB: ISplit = +{ + name: 'test', + status: 'ACTIVE', + conditions: [], + killed: false, + sets: ['set_a', 'set_b'] +}; +// @ts-ignore +const test2FFSetsX: ISplit = +{ + name: 'test2', + status: 'ACTIVE', + conditions: [], + killed: false, + sets: ['set_x'] +}; +// @ts-ignore +const testFFRemoveSetB: ISplit = +{ + name: 'test', + status: 'ACTIVE', + conditions: [], + sets: ['set_a'] +}; +// @ts-ignore +const testFFRemoveSetA: ISplit = +{ + name: 'test', + status: 'ACTIVE', + conditions: [], + sets: ['set_x'] +}; +// @ts-ignore +const testFFEmptySet: ISplit = +{ + name: 'test', + status: 'ACTIVE', + conditions: [], + sets: [] +}; test('splitChangesUpdater / segments parser', () => { @@ -48,12 +92,63 @@ test('splitChangesUpdater / segments parser', () => { }); test('splitChangesUpdater / compute splits mutation', () => { + const splitFiltersValidation = { queryString: null, groupedFilters: { bySet: [], byName: [], byPrefix: [] }, validFilters: [] }; - const splitsMutation = computeSplitsMutation([activeSplitWithSegments, archivedSplit] as ISplit[]); + let splitsMutation = computeSplitsMutation([activeSplitWithSegments, archivedSplit] as ISplit[], splitFiltersValidation); expect(splitsMutation.added).toEqual([[activeSplitWithSegments.name, activeSplitWithSegments]]); expect(splitsMutation.removed).toEqual([archivedSplit.name]); expect(splitsMutation.segments).toEqual(['A', 'B']); + + // SDK initialization without sets + // should process all the notifications + splitsMutation = computeSplitsMutation([testFFSetsAB, test2FFSetsX] as ISplit[], splitFiltersValidation); + + expect(splitsMutation.added).toEqual([[testFFSetsAB.name, testFFSetsAB],[test2FFSetsX.name, test2FFSetsX]]); + expect(splitsMutation.removed).toEqual([]); + expect(splitsMutation.segments).toEqual([]); +}); + +test('splitChangesUpdater / compute splits mutation with filters', () => { + // SDK initialization with sets: [set_a, set_b] + let splitFiltersValidation = { queryString: '&sets=set_a,set_b', groupedFilters: { bySet: ['set_a','set_b'], byName: ['name_1'], byPrefix: [] }, validFilters: [] }; + + // fetching new feature flag in sets A & B + let splitsMutation = computeSplitsMutation([testFFSetsAB], splitFiltersValidation); + + // should add it to mutations + expect(splitsMutation.added).toEqual([[testFFSetsAB.name, testFFSetsAB]]); + expect(splitsMutation.removed).toEqual([]); + + // fetching existing test feature flag removed from set B + splitsMutation = computeSplitsMutation([testFFRemoveSetB], splitFiltersValidation); + + expect(splitsMutation.added).toEqual([[testFFRemoveSetB.name, testFFRemoveSetB]]); + expect(splitsMutation.removed).toEqual([]); + + // fetching existing test feature flag removed from set B + splitsMutation = computeSplitsMutation([testFFRemoveSetA], splitFiltersValidation); + + expect(splitsMutation.added).toEqual([]); + expect(splitsMutation.removed).toEqual([testFFRemoveSetA.name]); + + // fetching existing test feature flag removed from set B + splitsMutation = computeSplitsMutation([testFFEmptySet], splitFiltersValidation); + + expect(splitsMutation.added).toEqual([]); + expect(splitsMutation.removed).toEqual([testFFEmptySet.name]); + + // SDK initialization with names: ['test2'] + splitFiltersValidation = { queryString: '&names=test2', groupedFilters: { bySet: [], byName: ['test2'], byPrefix: [] }, validFilters: [] }; + splitsMutation = computeSplitsMutation([testFFSetsAB], splitFiltersValidation); + + expect(splitsMutation.added).toEqual([]); + expect(splitsMutation.removed).toEqual([testFFSetsAB.name]); + + splitsMutation = computeSplitsMutation([test2FFSetsX, testFFEmptySet], splitFiltersValidation); + + expect(splitsMutation.added).toEqual([[test2FFSetsX.name, test2FFSetsX],]); + expect(splitsMutation.removed).toEqual([testFFEmptySet.name]); }); describe('splitChangesUpdater', () => { @@ -73,7 +168,9 @@ describe('splitChangesUpdater', () => { const readinessManager = readinessManagerFactory(EventEmitter); const splitsEmitSpy = jest.spyOn(readinessManager.splits, 'emit'); - const splitChangesUpdater = splitChangesUpdaterFactory(loggerMock, splitChangesFetcher, splitsCache, segmentsCache, readinessManager.splits, 1000, 1); + let splitFiltersValidation = { queryString: null, groupedFilters: { bySet: [], byName: [], byPrefix: [] }, validFilters: [] }; + + let splitChangesUpdater = splitChangesUpdaterFactory(loggerMock, splitChangesFetcher, splitsCache, segmentsCache, splitFiltersValidation, readinessManager.splits, 1000, 1); afterEach(() => { jest.clearAllMocks(); @@ -93,13 +190,12 @@ describe('splitChangesUpdater', () => { }); test('test with payload', async () => { - const ARCHIVED_FF = 'ARCHIVED'; let index = 0; for (const notification of splitNotifications) { - const payload = notification.decoded as ISplit; + const payload = notification.decoded as Pick; const changeNumber = payload.changeNumber; - await expect(splitChangesUpdater(undefined, undefined, { payload, changeNumber: changeNumber })).resolves.toBe(true); + await expect(splitChangesUpdater(undefined, undefined, { payload: {...payload, sets:[]}, changeNumber: changeNumber })).resolves.toBe(true); // fetch not being called expect(fetchSplitChanges).toBeCalledTimes(0); // Change number being updated @@ -117,4 +213,40 @@ describe('splitChangesUpdater', () => { index++; } }); + + test('flag sets splits-arrived emition', async () => { + const payload = splitNotifications[3].decoded as Pick; + const setMocks = [ + { sets: [], shouldEmit: false }, /* should not emit if flag does not have any set */ + { sets: ['set_a'], shouldEmit: true }, /* should emit if flag is in configured sets */ + { sets: ['set_b'], shouldEmit: true }, /* should emit if flag was just removed from configured sets */ + { sets: ['set_b'], shouldEmit: false }, /* should NOT emit if flag is nor was just removed from configured sets */ + { sets: ['set_c'], shouldEmit: false }, /* should NOT emit if flag is nor was just removed from configured sets */ + { sets: ['set_a'], shouldEmit: true }, /* should emit if flag is back in configured sets */ + ]; + + splitChangesUpdater = splitChangesUpdaterFactory(loggerMock, splitChangesFetcher, new SplitsCacheInMemory(), segmentsCache, splitFiltersValidation, readinessManager.splits, 1000, 1, true); + + let index = 0; + let calls = 0; + // emit always if not configured sets + for (const setMock of setMocks) { + await expect(splitChangesUpdater(undefined, undefined, { payload: {...payload, sets: setMock.sets, status: 'ACTIVE'}, changeNumber: index })).resolves.toBe(true); + expect(splitsEmitSpy.mock.calls[index][0]).toBe('state::splits-arrived'); + index++; + } + + // @ts-ignore + splitFiltersValidation = { queryString: null, groupedFilters: { bySet: ['set_a'], byName: [], byPrefix: [] }, validFilters: [] }; + splitChangesUpdater = splitChangesUpdaterFactory(loggerMock, splitChangesFetcher, new SplitsCacheInMemory(), segmentsCache, splitFiltersValidation, readinessManager.splits, 1000, 1, true); + splitsEmitSpy.mockReset(); + index = 0; + for (const setMock of setMocks) { + await expect(splitChangesUpdater(undefined, undefined, { payload: {...payload, sets: setMock.sets, status: 'ACTIVE'}, changeNumber: index })).resolves.toBe(true); + if (setMock.shouldEmit) calls++; + expect(splitsEmitSpy.mock.calls.length).toBe(calls); + index++; + } + + }); }); diff --git a/src/sync/polling/updaters/segmentChangesUpdater.ts b/src/sync/polling/updaters/segmentChangesUpdater.ts index 32f3fe7f..39d147ff 100644 --- a/src/sync/polling/updaters/segmentChangesUpdater.ts +++ b/src/sync/polling/updaters/segmentChangesUpdater.ts @@ -96,7 +96,7 @@ export function segmentChangesUpdaterFactory( if (error && error.statusCode === 403) { // If the operation is forbidden, it may be due to permissions. Destroy the SDK instance. // @TODO although factory status is destroyed, synchronization is not stopped - if (readiness) readiness.destroy(); + if (readiness) readiness.setDestroyed(); log.error(`${LOG_PREFIX_INSTANTIATION}: you passed a client-side type authorizationKey, please grab an SDK Key from the Split user interface that is of type server-side.`); } else { log.warn(`${LOG_PREFIX_SYNC_SEGMENTS}Error while doing fetch of segments. ${error}`); diff --git a/src/sync/polling/updaters/splitChangesUpdater.ts b/src/sync/polling/updaters/splitChangesUpdater.ts index 63ff08f0..583da077 100644 --- a/src/sync/polling/updaters/splitChangesUpdater.ts +++ b/src/sync/polling/updaters/splitChangesUpdater.ts @@ -1,12 +1,13 @@ import { _Set, setToArray, ISet } from '../../../utils/lang/sets'; import { ISegmentsCacheBase, ISplitsCacheBase } from '../../../storages/types'; import { ISplitChangesFetcher } from '../fetchers/types'; -import { ISplit, ISplitChangesResponse } from '../../../dtos/types'; +import { ISplit, ISplitChangesResponse, ISplitFiltersValidation } from '../../../dtos/types'; import { ISplitsEventEmitter } from '../../../readiness/types'; import { timeout } from '../../../utils/promise/timeout'; import { SDK_SPLITS_ARRIVED, SDK_SPLITS_CACHE_LOADED } from '../../../readiness/constants'; import { ILogger } from '../../../logger/types'; import { SYNC_SPLITS_FETCH, SYNC_SPLITS_NEW, SYNC_SPLITS_REMOVED, SYNC_SPLITS_SEGMENTS, SYNC_SPLITS_FETCH_FAILS, SYNC_SPLITS_FETCH_RETRY } from '../../../logger/constants'; +import { startsWith } from '../../../utils/lang'; type ISplitChangesUpdater = (noCache?: boolean, till?: number, splitUpdateNotification?: { payload: ISplit, changeNumber: number }) => Promise @@ -45,15 +46,36 @@ interface ISplitMutations { segments: string[] } +/** + * If there are defined filters and one feature flag doesn't match with them, its status is changed to 'ARCHIVE' to avoid storing it + * If there are set filter defined, names filter is ignored + * + * @param featureFlag feature flag to be evaluated + * @param filters splitFiltersValidation bySet | byName + */ +function matchFilters(featureFlag: ISplit, filters: ISplitFiltersValidation) { + const { bySet: setsFilter, byName: namesFilter, byPrefix: prefixFilter} = filters.groupedFilters; + if (setsFilter.length > 0) return featureFlag.sets && featureFlag.sets.some((featureFlagSet: string) => setsFilter.indexOf(featureFlagSet) > -1); + + const namesFilterConfigured = namesFilter.length > 0; + const prefixFilterConfigured = prefixFilter.length > 0; + + if (!namesFilterConfigured && !prefixFilterConfigured) return true; + + const matchNames = namesFilterConfigured && namesFilter.indexOf(featureFlag.name) > -1; + const matchPrefix = prefixFilterConfigured && prefixFilter.some(prefix => startsWith(featureFlag.name, prefix)); + return matchNames || matchPrefix; +} + /** * Given the list of splits from /splitChanges endpoint, it returns the mutations, * i.e., an object with added splits, removed splits and used segments. * Exported for testing purposes. */ -export function computeSplitsMutation(entries: ISplit[]): ISplitMutations { +export function computeSplitsMutation(entries: ISplit[], filters: ISplitFiltersValidation): ISplitMutations { const segments = new _Set(); const computed = entries.reduce((accum, split) => { - if (split.status === 'ACTIVE') { + if (split.status === 'ACTIVE' && matchFilters(split, filters)) { accum.added.push([split.name, split]); parseSegments(split).forEach((segmentName: string) => { @@ -90,6 +112,7 @@ export function splitChangesUpdaterFactory( splitChangesFetcher: ISplitChangesFetcher, splits: ISplitsCacheBase, segments: ISegmentsCacheBase, + splitFiltersValidation: ISplitFiltersValidation, splitsEventEmitter?: ISplitsEventEmitter, requestTimeoutBeforeReady: number = 0, retriesOnFailureBeforeReady: number = 0, @@ -104,6 +127,16 @@ export function splitChangesUpdaterFactory( return promise; } + /** Returns true if at least one split was updated */ + function isThereUpdate(flagsChange: [boolean | void, void | boolean[], void | boolean[], boolean | void] | [any, any, any]) { + const [, added, removed, ] = flagsChange; + // There is at least one added or modified feature flag + if (added && added.some((update: boolean) => update)) return true; + // There is at least one removed feature flag + if (removed && removed.some((update: boolean) => update)) return true; + return false; + } + /** * SplitChanges updater returns a promise that resolves with a `false` boolean value if it fails to fetch splits or synchronize them with the storage. * Returned promise will not be rejected. @@ -126,7 +159,7 @@ export function splitChangesUpdaterFactory( .then((splitChanges: ISplitChangesResponse) => { startingUp = false; - const mutation = computeSplitsMutation(splitChanges.splits); + const mutation = computeSplitsMutation(splitChanges.splits, splitFiltersValidation); log.debug(SYNC_SPLITS_NEW, [mutation.added.length]); log.debug(SYNC_SPLITS_REMOVED, [mutation.removed.length]); @@ -140,11 +173,10 @@ export function splitChangesUpdaterFactory( splits.addSplits(mutation.added), splits.removeSplits(mutation.removed), segments.registerSegments(mutation.segments) - ]).then(() => { - + ]).then((flagsChange) => { if (splitsEventEmitter) { // To emit SDK_SPLITS_ARRIVED for server-side SDK, we must check that all registered segments have been fetched - return Promise.resolve(!splitsEventEmitter.splitsArrived || (since !== splitChanges.till && (isClientSide || checkAllSegmentsExist(segments)))) + return Promise.resolve(!splitsEventEmitter.splitsArrived || (since !== splitChanges.till && isThereUpdate(flagsChange) && (isClientSide || checkAllSegmentsExist(segments)))) .catch(() => false /** noop. just to handle a possible `checkAllSegmentsExist` rejection, before emitting SDK event */) .then(emitSplitsArrivedEvent => { // emit SDK events diff --git a/src/sync/submitters/__tests__/telemetrySubmitter.spec.ts b/src/sync/submitters/__tests__/telemetrySubmitter.spec.ts index be7b6542..000dc0a7 100644 --- a/src/sync/submitters/__tests__/telemetrySubmitter.spec.ts +++ b/src/sync/submitters/__tests__/telemetrySubmitter.spec.ts @@ -77,7 +77,7 @@ describe('Telemetry submitter', () => { expect(recordTimeUntilReadySpy).toBeCalledTimes(1); expect(postMetricsConfig).toBeCalledWith(JSON.stringify({ - oM: 0, st: 'memory', aF: 0, rF: 0, sE: true, rR: { sp: 0.001, se: 0.001, im: 0.001, ev: 0.001, te: 0.1 }, uO: { s: true, e: true, a: true, st: true, t: true }, iQ: 1, eQ: 1, iM: 0, iL: false, hP: false, tR: 0, tC: 0, nR: 0, t: [], i: ['NoopIntegration'], uC: 0 + oM: 0, st: 'memory', aF: 0, rF: 0, sE: true, rR: { sp: 0.001, se: 0.001, im: 0.001, ev: 0.001, te: 0.1 }, uO: { s: true, e: true, a: true, st: true, t: true }, iQ: 1, eQ: 1, iM: 0, iL: false, hP: false, tR: 0, tC: 0, nR: 0, t: [], i: ['NoopIntegration'], uC: 0, fsT: 0, fsI: 0 })); // Stop submitter, to not execute the 1st periodic metrics/usage POST diff --git a/src/sync/submitters/telemetrySubmitter.ts b/src/sync/submitters/telemetrySubmitter.ts index a72d40b6..a2289e08 100644 --- a/src/sync/submitters/telemetrySubmitter.ts +++ b/src/sync/submitters/telemetrySubmitter.ts @@ -3,12 +3,13 @@ import { submitterFactory, firstPushWindowDecorator } from './submitter'; import { TelemetryConfigStatsPayload, TelemetryConfigStats } from './types'; import { CONSUMER_MODE, CONSUMER_ENUM, STANDALONE_MODE, CONSUMER_PARTIAL_MODE, STANDALONE_ENUM, CONSUMER_PARTIAL_ENUM, OPTIMIZED, DEBUG, NONE, DEBUG_ENUM, OPTIMIZED_ENUM, NONE_ENUM, CONSENT_GRANTED, CONSENT_DECLINED, CONSENT_UNKNOWN } from '../../utils/constants'; import { SDK_READY, SDK_READY_FROM_CACHE } from '../../readiness/constants'; -import { ConsentStatus, ISettings, SDKMode } from '../../types'; +import { ConsentStatus, ISettings, SDKMode, SplitIO } from '../../types'; import { base } from '../../utils/settingsValidation'; import { usedKeysMap } from '../../utils/inputValidation/apiKey'; import { timer } from '../../utils/timeTracker/timer'; import { ISdkFactoryContextSync } from '../../sdkFactory/types'; import { objectAssign } from '../../utils/lang/objectAssign'; +import { ISplitFiltersValidation } from '../../dtos/types'; const OPERATION_MODE_MAP = { [STANDALONE_MODE]: STANDALONE_ENUM, @@ -38,6 +39,18 @@ function getRedundantActiveFactories() { }, 0); } +function getTelemetryFlagSetsStats(splitFiltersValidation: ISplitFiltersValidation) { + // Group every configured flag set in an unique array called originalSets + let flagSetsTotal = 0; + splitFiltersValidation.validFilters.forEach((filter: SplitIO.SplitFilter) => { + if (filter.type === 'bySet') flagSetsTotal += filter.values.length; + }); + + const flagSetsValid = splitFiltersValidation.groupedFilters.bySet.length; + const flagSetsIgnored = flagSetsTotal - flagSetsValid; + return { flagSetsTotal, flagSetsIgnored }; +} + export function getTelemetryConfigStats(mode: SDKMode, storageType: string): TelemetryConfigStats { return { oM: OPERATION_MODE_MAP[mode], // @ts-ignore lower case of storage type @@ -59,6 +72,8 @@ export function telemetryCacheConfigAdapter(telemetry: ITelemetryCacheSync, sett const { urls, scheduler } = settings; const isClientSide = settings.core.key !== undefined; + const { flagSetsTotal, flagSetsIgnored } = getTelemetryFlagSetsStats(settings.sync.__splitFiltersValidation); + return objectAssign(getTelemetryConfigStats(settings.mode, settings.storage.type), { sE: settings.streamingEnabled, rR: { @@ -86,7 +101,9 @@ export function telemetryCacheConfigAdapter(telemetry: ITelemetryCacheSync, sett nR: telemetry.getNonReadyUsage(), t: telemetry.popTags(), i: settings.integrations && settings.integrations.map(int => int.type), - uC: settings.userConsent ? USER_CONSENT_MAP[settings.userConsent] : 0 + uC: settings.userConsent ? USER_CONSENT_MAP[settings.userConsent] : 0, + fsT: flagSetsTotal, + fsI: flagSetsIgnored }); } }; diff --git a/src/sync/submitters/types.ts b/src/sync/submitters/types.ts index 95653fec..440f466a 100644 --- a/src/sync/submitters/types.ts +++ b/src/sync/submitters/types.ts @@ -124,7 +124,11 @@ export type TREATMENTS = 'ts'; export type TREATMENT_WITH_CONFIG = 'tc'; export type TREATMENTS_WITH_CONFIG = 'tcs'; export type TRACK = 'tr'; -export type Method = TREATMENT | TREATMENTS | TREATMENT_WITH_CONFIG | TREATMENTS_WITH_CONFIG | TRACK; +export type TREATMENTS_BY_FLAGSET = 'tf' +export type TREATMENTS_BY_FLAGSETS = 'tfs' +export type TREATMENTS_WITH_CONFIG_BY_FLAGSET = 'tcf' +export type TREATMENTS_WITH_CONFIG_BY_FLAGSETS = 'tcfs' +export type Method = TREATMENT | TREATMENTS | TREATMENT_WITH_CONFIG | TREATMENTS_WITH_CONFIG | TRACK | TREATMENTS_BY_FLAGSET | TREATMENTS_BY_FLAGSETS | TREATMENTS_WITH_CONFIG_BY_FLAGSET | TREATMENTS_WITH_CONFIG_BY_FLAGSETS; export type MethodLatencies = Partial>>; @@ -234,6 +238,8 @@ export type TelemetryConfigStatsPayload = TelemetryConfigStats & { nR: number, // SDKNotReadyUsage i?: Array, // integrations uC: number, // userConsent + fsT: number, // flagSetsTotal + fsI: number, // flagSetsInvalid } export interface ISubmitterManager extends ISyncTask { diff --git a/src/types.ts b/src/types.ts index d868b92a..b71f6e80 100644 --- a/src/types.ts +++ b/src/types.ts @@ -609,7 +609,12 @@ export namespace SplitIO { */ configs: { [treatmentName: string]: string - } + }, + /** + * List of sets of the feature flag. + * @property {string[]} sets + */ + sets: string[], /** * The default treatment of the feature flag. * @property {string} defaultTreatment @@ -714,7 +719,7 @@ export namespace SplitIO { * SplitFilter type. * @typedef {string} SplitFilterType */ - export type SplitFilterType = 'byName' | 'byPrefix'; + export type SplitFilterType = 'byName' | 'byPrefix' | 'bySet'; /** * Defines a feature flag filter, described by a type and list of values. */ @@ -1064,6 +1069,42 @@ export namespace SplitIO { * @returns {TreatmentsWithConfig} The map with all the TreatmentWithConfig objects */ getTreatmentsWithConfig(key: SplitKey, featureFlagNames: string[], attributes?: Attributes): TreatmentsWithConfig, + /** + * Returns a Treatments value, which is an object map with the treatments for the feature flags related to the given flagSet. + * @function getTreatmentsByFlagSet + * @param {string} key - The string key representing the consumer. + * @param {string} flagSet - The flagSet name we want to get the treatments. + * @param {Attributes=} attributes - An object of type Attributes defining the attributes for the given key. + * @returns {Treatments} The map with all the TreatmentWithConfig objects + */ + getTreatmentsByFlagSet(key: SplitKey, flagSet: string, attributes?: Attributes): Treatments, + /** + * Returns a TreatmentsWithConfig value, which is an object map with the TreatmentWithConfig (an object with both treatment and config string) for the feature flags related to the given flagSets. + * @function getTreatmentsWithConfigByFlagSet + * @param {string} key - The string key representing the consumer. + * @param {string} flagSet - The flagSet name we want to get the treatments. + * @param {Attributes=} attributes - An object of type Attributes defining the attributes for the given key. + * @returns {Treatments} The map with all the TreatmentWithConfig objects + */ + getTreatmentsWithConfigByFlagSet(key: SplitKey, flagSet: string, attributes?: Attributes): TreatmentsWithConfig, + /** + * Returns a Returns a Treatments value, which is an object with both treatment and config string for to the feature flags related to the given flagSets. + * @function getTreatmentsByFlagSets + * @param {string} key - The string key representing the consumer. + * @param {Array} flagSets - An array of the flagSet names we want to get the treatments. + * @param {Attributes=} attributes - An object of type Attributes defining the attributes for the given key. + * @returns {Treatments} The map with all the TreatmentWithConfig objects + */ + getTreatmentsByFlagSets(key: SplitKey, flagSets: string[], attributes?: Attributes): Treatments, + /** + * Returns a TreatmentsWithConfig value, which is an object map with the TreatmentWithConfig (an object with both treatment and config string) for the feature flags related to the given flagSets. + * @function getTreatmentsWithConfigByFlagSets + * @param {string} key - The string key representing the consumer. + * @param {Array} flagSets - An array of the flagSet names we want to get the treatments. + * @param {Attributes=} attributes - An object of type Attributes defining the attributes for the given key. + * @returns {Treatments} The map with all the TreatmentWithConfig objects + */ + getTreatmentsWithConfigByFlagSets(key: SplitKey, flagSets: string[], attributes?: Attributes): TreatmentsWithConfig, /** * Tracks an event to be fed to the results product on Split user interface. * @function track @@ -1124,6 +1165,46 @@ export namespace SplitIO { * @returns {AsyncTreatmentsWithConfig} TreatmentsWithConfig promise that resolves to the map of TreatmentsWithConfig objects. */ getTreatmentsWithConfig(key: SplitKey, featureFlagNames: string[], attributes?: Attributes): AsyncTreatmentsWithConfig, + /** + * Returns a Treatments value, which will be (or eventually be) an object map with the treatments for the features related to the given flag set. + * For usage on NodeJS as we don't have only one key. + * @function getTreatmentsByFlagSet + * @param {string} key - The string key representing the consumer. + * @param {string} flagSet - The flag set name we want to get the treatments. + * @param {Attributes=} attributes - An object of type Attributes defining the attributes for the given key. + * @returns {Treatments} The map with all the TreatmentWithConfig objects + */ + getTreatmentsByFlagSet(key: SplitKey, flagSet: string, attributes?: Attributes): AsyncTreatments, + /** + * Returns a TreatmentWithConfig value, which will be (or eventually be) an object with both treatment and config string for features related to the given flag set. + * For usage on NodeJS as we don't have only one key. + * @function getTreatmentsWithConfigByFlagSet + * @param {string} key - The string key representing the consumer. + * @param {string} flagSet - The flag set name we want to get the treatments. + * @param {Attributes=} attributes - An object of type Attributes defining the attributes for the given key. + * @returns {Treatments} The map with all the TreatmentWithConfig objects + */ + getTreatmentsWithConfigByFlagSet(key: SplitKey, flagSet: string, attributes?: Attributes): AsyncTreatmentsWithConfig, + /** + * Returns a Treatments value, which will be (or eventually be) an object map with the treatments for the feature flags related to the given flag sets. + * For usage on NodeJS as we don't have only one key. + * @function getTreatmentsByFlagSets + * @param {string} key - The string key representing the consumer. + * @param {Array} flagSets - An array of the flag set names we want to get the treatments. + * @param {Attributes=} attributes - An object of type Attributes defining the attributes for the given key. + * @returns {Treatments} The map with all the TreatmentWithConfig objects + */ + getTreatmentsByFlagSets(key: SplitKey, flagSets: string[], attributes?: Attributes): AsyncTreatments, + /** + * Returns a TreatmentWithConfig value, which will be (or eventually be) an object with both treatment and config string for the feature flags related to the given flag sets. + * For usage on NodeJS as we don't have only one key. + * @function getTreatmentsWithConfigByFlagSets + * @param {string} key - The string key representing the consumer. + * @param {Array} flagSets - An array of the flag set names we want to get the treatments. + * @param {Attributes=} attributes - An object of type Attributes defining the attributes for the given key. + * @returns {Treatments} The map with all the TreatmentWithConfig objects + */ + getTreatmentsWithConfigByFlagSets(key: SplitKey, flagSets: string[], attributes?: Attributes): AsyncTreatmentsWithConfig, /** * Tracks an event to be fed to the results product on Split user interface, and returns a promise to signal when the event was successfully queued (or not). * @function track @@ -1174,6 +1255,38 @@ export namespace SplitIO { * @returns {TreatmentsWithConfig} The map with all the TreatmentWithConfig objects */ getTreatmentsWithConfig(featureFlagNames: string[], attributes?: Attributes): TreatmentsWithConfig, + /** + * Returns a Treatments value, which is an object map with the treatments for the feature flags related to the given flag set. + * @function getTreatmentsByFlagSet + * @param {string} flagSet - The flag set name we want to get the treatments. + * @param {Attributes=} attributes - An object of type Attributes defining the attributes for the given key. + * @returns {Treatments} The map with all the TreatmentWithConfig objects + */ + getTreatmentsByFlagSet(flagSet: string, attributes?: Attributes): Treatments, + /** + * Returns a TreatmentsWithConfig value, which is an object map with the TreatmentWithConfig (an object with both treatment and config string) for the feature flags related to the given flag set. + * @function getTreatmentsWithConfigByFlagSet + * @param {string} flagSet - The flag set name we want to get the treatments. + * @param {Attributes=} attributes - An object of type Attributes defining the attributes for the given key. + * @returns {Treatments} The map with all the TreatmentWithConfig objects + */ + getTreatmentsWithConfigByFlagSet(flagSet: string, attributes?: Attributes): TreatmentsWithConfig, + /** + * Returns a Returns a Treatments value, which is an object with both treatment and config string for to the feature flags related to the given flag sets. + * @function getTreatmentsByFlagSets + * @param {Array} flagSets - An array of the flag set names we want to get the treatments. + * @param {Attributes=} attributes - An object of type Attributes defining the attributes for the given key. + * @returns {Treatments} The map with all the TreatmentWithConfig objects + */ + getTreatmentsByFlagSets(flagSets: string[], attributes?: Attributes): Treatments, + /** + * Returns a TreatmentsWithConfig value, which is an object map with the TreatmentWithConfig (an object with both treatment and config string) for the feature flags related to the given flag sets. + * @function getTreatmentsWithConfigByFlagSets + * @param {Array} flagSets - An array of the flag set names we want to get the treatments. + * @param {Attributes=} attributes - An object of type Attributes defining the attributes for the given key. + * @returns {Treatments} The map with all the TreatmentWithConfig objects + */ + getTreatmentsWithConfigByFlagSets(flagSets: string[], attributes?: Attributes): TreatmentsWithConfig, /** * Tracks an event to be fed to the results product on Split user interface. * @function track diff --git a/src/utils/constants/index.ts b/src/utils/constants/index.ts index c55984a3..18dc1ccb 100644 --- a/src/utils/constants/index.ts +++ b/src/utils/constants/index.ts @@ -65,6 +65,10 @@ export const TREATMENT = 't'; export const TREATMENTS = 'ts'; export const TREATMENT_WITH_CONFIG = 'tc'; export const TREATMENTS_WITH_CONFIG = 'tcs'; +export const TREATMENTS_BY_FLAGSET = 'tf'; +export const TREATMENTS_BY_FLAGSETS = 'tfs'; +export const TREATMENTS_WITH_CONFIG_BY_FLAGSET = 'tcf'; +export const TREATMENTS_WITH_CONFIG_BY_FLAGSETS = 'tcfs'; export const TRACK = 'tr'; export const CONNECTION_ESTABLISHED = 0; diff --git a/src/utils/lang/__tests__/sets.spec.ts b/src/utils/lang/__tests__/sets.spec.ts index ae01dded..1cb99853 100644 --- a/src/utils/lang/__tests__/sets.spec.ts +++ b/src/utils/lang/__tests__/sets.spec.ts @@ -1,4 +1,4 @@ -import { __getSetConstructor, SetPoly } from '../sets'; +import { __getSetConstructor, _Set, returnSetsUnion, SetPoly } from '../sets'; test('__getSetConstructor', () => { @@ -14,3 +14,16 @@ test('__getSetConstructor', () => { global.Set = originalSet; // restore original global Set }); + +test('returnSetsUnion', () => { + const set = new _Set(['1','2','3']); + const set2 = new _Set(['4','5','6']); + expect(returnSetsUnion(set, set2)).toEqual(new _Set(['1','2','3','4','5','6'])); + expect(set).toEqual(new _Set(['1','2','3'])); + expect(set2).toEqual(new _Set(['4','5','6'])); + + const emptySet = new _Set([]); + expect(returnSetsUnion(emptySet, emptySet)).toEqual(emptySet); + expect(returnSetsUnion(set, emptySet)).toEqual(set); + expect(returnSetsUnion(emptySet, set2)).toEqual(set2); +}); diff --git a/src/utils/lang/sets.ts b/src/utils/lang/sets.ts index 6ecb157d..f89fc29f 100644 --- a/src/utils/lang/sets.ts +++ b/src/utils/lang/sets.ts @@ -111,3 +111,11 @@ export function __getSetConstructor(): ISetConstructor { } export const _Set = __getSetConstructor(); + +export function returnSetsUnion(set: ISet, set2: ISet): ISet { + const result = new _Set(setToArray(set)); + set2.forEach( value => { + result.add(value); + }); + return result; +} diff --git a/src/utils/settingsValidation/__tests__/settings.mocks.ts b/src/utils/settingsValidation/__tests__/settings.mocks.ts index ad8d812a..ac2b8293 100644 --- a/src/utils/settingsValidation/__tests__/settings.mocks.ts +++ b/src/utils/settingsValidation/__tests__/settings.mocks.ts @@ -77,7 +77,7 @@ export const fullSettings: ISettings = { __splitFiltersValidation: { validFilters: [], queryString: null, - groupedFilters: { byName: [], byPrefix: [] } + groupedFilters: { bySet: [], byName: [], byPrefix: [] }, }, enabled: true }, diff --git a/src/utils/settingsValidation/__tests__/splitFilters.spec.ts b/src/utils/settingsValidation/__tests__/splitFilters.spec.ts index 1e23b752..5fe5dfb2 100644 --- a/src/utils/settingsValidation/__tests__/splitFilters.spec.ts +++ b/src/utils/settingsValidation/__tests__/splitFilters.spec.ts @@ -1,36 +1,44 @@ import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; -import { STANDALONE_MODE, CONSUMER_MODE } from '../../constants'; - // Split filter and QueryStrings examples import { splitFilters, queryStrings, groupedFilters } from '../../../__tests__/mocks/fetchSpecificSplits'; // Test target -import { validateSplitFilters } from '../splitFilters'; -import { SETTINGS_SPLITS_FILTER, ERROR_INVALID, ERROR_EMPTY_ARRAY, WARN_SPLITS_FILTER_IGNORED, WARN_SPLITS_FILTER_INVALID, WARN_SPLITS_FILTER_EMPTY } from '../../../logger/constants'; +import { flagSetsAreValid, validateSplitFilters } from '../splitFilters'; +import { SETTINGS_SPLITS_FILTER, ERROR_INVALID, ERROR_EMPTY_ARRAY, ERROR_SETS_FILTER_EXCLUSIVE, WARN_SPLITS_FILTER_INVALID, WARN_SPLITS_FILTER_EMPTY, WARN_TRIMMING, WARN_SPLITS_FILTER_INVALID_SET, WARN_SPLITS_FILTER_LOWERCASE_SET, WARN_FLAGSET_NOT_CONFIGURED } from '../../../logger/constants'; describe('validateSplitFilters', () => { const defaultOutput = { validFilters: [], queryString: null, - groupedFilters: { byName: [], byPrefix: [] } + groupedFilters: { bySet: [], byName: [], byPrefix: [] }, + }; + + const getOutput = (testIndex: number) => { + return { + // @ts-ignore + validFilters: splitFilters[testIndex], + queryString: queryStrings[testIndex], + groupedFilters: groupedFilters[testIndex], + }; }; + const regexp = /^[a-z0-9][_a-z0-9]{0,49}$/; + afterEach(() => { loggerMock.mockClear(); }); test('Returns default output with empty values if `splitFilters` is an invalid object or `mode` is not \'standalone\'', () => { - expect(validateSplitFilters(loggerMock, undefined, STANDALONE_MODE)).toEqual(defaultOutput); // splitFilters ignored if not a non-empty array - expect(validateSplitFilters(loggerMock, null, STANDALONE_MODE)).toEqual(defaultOutput); // splitFilters ignored if not a non-empty array + expect(validateSplitFilters(loggerMock, undefined)).toEqual(defaultOutput); // splitFilters ignored if not a non-empty array + expect(validateSplitFilters(loggerMock, null)).toEqual(defaultOutput); // splitFilters ignored if not a non-empty array expect(loggerMock.warn).not.toBeCalled(); - expect(validateSplitFilters(loggerMock, true, STANDALONE_MODE)).toEqual(defaultOutput); // splitFilters ignored if not a non-empty array - expect(validateSplitFilters(loggerMock, 15, STANDALONE_MODE)).toEqual(defaultOutput); // splitFilters ignored if not a non-empty array - expect(validateSplitFilters(loggerMock, 'string', STANDALONE_MODE)).toEqual(defaultOutput); // splitFilters ignored if not a non-empty array - expect(validateSplitFilters(loggerMock, [], STANDALONE_MODE)).toEqual(defaultOutput); // splitFilters ignored if not a non-empty array - expect(validateSplitFilters(loggerMock, [{ type: 'byName', values: ['split_1'] }], CONSUMER_MODE)).toEqual(defaultOutput); // splitFilters ignored if not in 'standalone' mode - expect(loggerMock.warn.mock.calls).toEqual([[WARN_SPLITS_FILTER_EMPTY], [WARN_SPLITS_FILTER_EMPTY], [WARN_SPLITS_FILTER_EMPTY], [WARN_SPLITS_FILTER_EMPTY], [WARN_SPLITS_FILTER_IGNORED, ['standalone']]]); + expect(validateSplitFilters(loggerMock, true)).toEqual(defaultOutput); // splitFilters ignored if not a non-empty array + expect(validateSplitFilters(loggerMock, 15)).toEqual(defaultOutput); // splitFilters ignored if not a non-empty array + expect(validateSplitFilters(loggerMock, 'string')).toEqual(defaultOutput); // splitFilters ignored if not a non-empty array + expect(validateSplitFilters(loggerMock, [])).toEqual(defaultOutput); // splitFilters ignored if not a non-empty array + expect(loggerMock.warn.mock.calls).toEqual([[WARN_SPLITS_FILTER_EMPTY], [WARN_SPLITS_FILTER_EMPTY], [WARN_SPLITS_FILTER_EMPTY], [WARN_SPLITS_FILTER_EMPTY]]); expect(loggerMock.debug).not.toBeCalled(); expect(loggerMock.error).not.toBeCalled(); @@ -39,15 +47,16 @@ describe('validateSplitFilters', () => { test('Returns object with null queryString, if `splitFilters` contains invalid filters or contains filters with no values or invalid values', () => { const splitFilters: any[] = [ + { type: 'bySet', values: [] }, { type: 'byName', values: [] }, { type: 'byName', values: [] }, { type: 'byPrefix', values: [] }]; const output = { validFilters: [...splitFilters], queryString: null, - groupedFilters: { byName: [], byPrefix: [] } + groupedFilters: { bySet: [], byName: [], byPrefix: [] }, }; - expect(validateSplitFilters(loggerMock, splitFilters, STANDALONE_MODE)).toEqual(output); // filters without values + expect(validateSplitFilters(loggerMock, splitFilters)).toEqual(output); // filters without values expect(loggerMock.debug).toBeCalledWith(SETTINGS_SPLITS_FILTER, [null]); loggerMock.debug.mockClear(); @@ -57,12 +66,12 @@ describe('validateSplitFilters', () => { { type: null, values: [] }, { type: 'byName', values: [13] }); output.validFilters.push({ type: 'byName', values: [13] }); - expect(validateSplitFilters(loggerMock, splitFilters, STANDALONE_MODE)).toEqual(output); // some filters are invalid + expect(validateSplitFilters(loggerMock, splitFilters)).toEqual(output); // some filters are invalid expect(loggerMock.debug.mock.calls).toEqual([[SETTINGS_SPLITS_FILTER, [null]]]); expect(loggerMock.warn.mock.calls).toEqual([ - [WARN_SPLITS_FILTER_INVALID, [3]], // invalid value of `type` property - [WARN_SPLITS_FILTER_INVALID, [4]], // invalid type of `values` property - [WARN_SPLITS_FILTER_INVALID, [5]] // invalid type of `type` property + [WARN_SPLITS_FILTER_INVALID, [4]], // invalid value of `type` property + [WARN_SPLITS_FILTER_INVALID, [5]], // invalid type of `values` property + [WARN_SPLITS_FILTER_INVALID, [6]] // invalid type of `type` property ]); expect(loggerMock.error.mock.calls).toEqual([ @@ -73,21 +82,116 @@ describe('validateSplitFilters', () => { test('Returns object with a queryString, if `splitFilters` contains at least a valid `byName` or `byPrefix` filter with at least a valid value', () => { - for (let i = 0; i < splitFilters.length; i++) { + for (let i = 0; i < 6; i++) { if (groupedFilters[i]) { // tests where validateSplitFilters executes normally const output = { validFilters: [...splitFilters[i]], queryString: queryStrings[i], - groupedFilters: groupedFilters[i] + groupedFilters: groupedFilters[i], }; - expect(validateSplitFilters(loggerMock, splitFilters[i], STANDALONE_MODE)).toEqual(output); // splitFilters #${i} + expect(validateSplitFilters(loggerMock, splitFilters[i])).toEqual(output); // splitFilters #${i} expect(loggerMock.debug).lastCalledWith(SETTINGS_SPLITS_FILTER, [queryStrings[i]]); } else { // tests where validateSplitFilters throws an exception - expect(() => validateSplitFilters(loggerMock, splitFilters[i], STANDALONE_MODE)).toThrow(queryStrings[i]); + expect(() => validateSplitFilters(loggerMock, splitFilters[i])).toThrow(queryStrings[i]); } } }); + test('Validates flag set filters', () => { + // extra spaces trimmed and sorted query output + expect(validateSplitFilters(loggerMock, splitFilters[6])).toEqual(getOutput(6)); // trim & sort + expect(loggerMock.warn.mock.calls[0]).toEqual([WARN_TRIMMING, ['settings', 'bySet filter value', ' set_1']]); + expect(loggerMock.warn.mock.calls[1]).toEqual([WARN_TRIMMING, ['settings', 'bySet filter value', 'set_3 ']]); + expect(loggerMock.warn.mock.calls[2]).toEqual([WARN_TRIMMING, ['settings', 'bySet filter value', ' set_a ']]); + expect(loggerMock.error.mock.calls[0]).toEqual([ERROR_SETS_FILTER_EXCLUSIVE]); + + expect(validateSplitFilters(loggerMock, splitFilters[7])).toEqual(getOutput(7)); // lowercase and regexp + expect(loggerMock.warn.mock.calls[3]).toEqual([WARN_SPLITS_FILTER_LOWERCASE_SET, ['seT_c']]); // lowercase + expect(loggerMock.warn.mock.calls[4]).toEqual([WARN_SPLITS_FILTER_LOWERCASE_SET, ['set_B']]); // lowercase + expect(loggerMock.warn.mock.calls[5]).toEqual([WARN_SPLITS_FILTER_INVALID_SET, ['set_ 1', regexp, 'set_ 1']]); // empty spaces + expect(loggerMock.warn.mock.calls[6]).toEqual([WARN_SPLITS_FILTER_INVALID_SET, ['set _3', regexp, 'set _3']]); // empty spaces + expect(loggerMock.warn.mock.calls[7]).toEqual([WARN_SPLITS_FILTER_INVALID_SET, ['_set_2', regexp, '_set_2']]); // start with a letter + expect(loggerMock.warn.mock.calls[8]).toEqual([WARN_SPLITS_FILTER_INVALID_SET, ['set_1234567890_1234567890_234567890_1234567890_1234567890', regexp, 'set_1234567890_1234567890_234567890_1234567890_1234567890']]); // max of 50 characters + expect(loggerMock.error.mock.calls[1]).toEqual([ERROR_SETS_FILTER_EXCLUSIVE]); + + expect(validateSplitFilters(loggerMock, splitFilters[8])).toEqual(getOutput(8)); // lowercase and dedupe + expect(loggerMock.warn.mock.calls[9]).toEqual([WARN_SPLITS_FILTER_LOWERCASE_SET, ['SET_2']]); // lowercase + expect(loggerMock.warn.mock.calls[10]).toEqual([WARN_SPLITS_FILTER_LOWERCASE_SET, ['set_B']]); // lowercase + expect(loggerMock.warn.mock.calls[11]).toEqual([WARN_SPLITS_FILTER_INVALID_SET, ['set_3!', regexp, 'set_3!']]); // special character + expect(loggerMock.error.mock.calls[2]).toEqual([ERROR_SETS_FILTER_EXCLUSIVE]); + + expect(loggerMock.warn.mock.calls.length).toEqual(12); + expect(loggerMock.error.mock.calls.length).toEqual(3); + }); + + test('flagSetsAreValid - Flag set validation for evaluations', () => { + + let flagSetsFilter = ['set_1', 'set_2']; + + // empty array + expect(flagSetsAreValid(loggerMock, 'test_method', [], flagSetsFilter)).toEqual([]); + + // must start with a letter or number + expect(flagSetsAreValid(loggerMock, 'test_method', ['_set_1'], flagSetsFilter)).toEqual([]); + expect(loggerMock.warn.mock.calls[0]).toEqual([WARN_SPLITS_FILTER_INVALID_SET, ['_set_1', regexp, '_set_1']]); + + // can contain _a-z0-9 + expect(flagSetsAreValid(loggerMock, 'test_method', ['set*1'], flagSetsFilter)).toEqual([]); + expect(loggerMock.warn.mock.calls[1]).toEqual([WARN_SPLITS_FILTER_INVALID_SET, ['set*1', regexp, 'set*1']]); + + // have a max length of 50 characters + const longName = '1234567890_1234567890_1234567890_1234567890_1234567890'; + expect(flagSetsAreValid(loggerMock, 'test_method', [longName], flagSetsFilter)).toEqual([]); + expect(loggerMock.warn.mock.calls[2]).toEqual([WARN_SPLITS_FILTER_INVALID_SET, [longName, regexp, longName]]); + + // both set names invalid -> empty list & warn + expect(flagSetsAreValid(loggerMock, 'test_method', ['set*1','set*3'], flagSetsFilter)).toEqual([]); + expect(loggerMock.warn.mock.calls[3]).toEqual([WARN_SPLITS_FILTER_INVALID_SET, ['set*1', regexp, 'set*1']]); + expect(loggerMock.warn.mock.calls[4]).toEqual([WARN_SPLITS_FILTER_INVALID_SET, ['set*3', regexp, 'set*3']]); + + // only set_1 is valid => [set_1] & warn + expect(flagSetsAreValid(loggerMock, 'test_method', ['set_1','set*3'], flagSetsFilter)).toEqual(['set_1']); + expect(loggerMock.warn.mock.calls[5]).toEqual([WARN_SPLITS_FILTER_INVALID_SET, ['set*3', regexp, 'set*3']]); + + // set_3 not included in configuration but set_1 included => [set_1] & warn + expect(flagSetsAreValid(loggerMock, 'test_method', ['set_1','set_3'], flagSetsFilter)).toEqual(['set_1']); + expect(loggerMock.warn.mock.calls[6]).toEqual([WARN_FLAGSET_NOT_CONFIGURED, ['test_method', 'set_3']]); + + // set_3 not included in configuration => [] & warn + expect(flagSetsAreValid(loggerMock, 'test_method', ['set_3'], flagSetsFilter)).toEqual([]); + expect(loggerMock.warn.mock.calls[7]).toEqual([WARN_FLAGSET_NOT_CONFIGURED, ['test_method','set_3']]); + + // empty config + + + // must start with a letter or number + expect(flagSetsAreValid(loggerMock, 'test_method', ['_set_1'], [])).toEqual([]); + expect(loggerMock.warn.mock.calls[8]).toEqual([WARN_SPLITS_FILTER_INVALID_SET, ['_set_1', regexp, '_set_1']]); + + // can contain _a-z0-9 + expect(flagSetsAreValid(loggerMock, 'test_method', ['set*1'], [])).toEqual([]); + expect(loggerMock.warn.mock.calls[9]).toEqual([WARN_SPLITS_FILTER_INVALID_SET, ['set*1', regexp, 'set*1']]); + + // have a max length of 50 characters + expect(flagSetsAreValid(loggerMock, 'test_method', [longName], [])).toEqual([]); + expect(loggerMock.warn.mock.calls[10]).toEqual([WARN_SPLITS_FILTER_INVALID_SET, [longName, regexp, longName]]); + + // both set names invalid -> empty list & warn + expect(flagSetsAreValid(loggerMock, 'test_method', ['set*1','set*3'], [])).toEqual([]); + expect(loggerMock.warn.mock.calls[11]).toEqual([WARN_SPLITS_FILTER_INVALID_SET, ['set*1', regexp, 'set*1']]); + expect(loggerMock.warn.mock.calls[12]).toEqual([WARN_SPLITS_FILTER_INVALID_SET, ['set*3', regexp, 'set*3']]); + + // only set_1 is valid => [set_1] & warn + expect(flagSetsAreValid(loggerMock, 'test_method', ['set_1','set*3'], [])).toEqual(['set_1']); + expect(loggerMock.warn.mock.calls[13]).toEqual([WARN_SPLITS_FILTER_INVALID_SET, ['set*3', regexp, 'set*3']]); + + // any set should be returned if there isn't flag sets in filter + expect(flagSetsAreValid(loggerMock, 'test_method', ['set_1'], [])).toEqual(['set_1']); + expect(flagSetsAreValid(loggerMock, 'test_method', ['set_1','set_2'], [])).toEqual(['set_1','set_2']); + expect(flagSetsAreValid(loggerMock, 'test_method', ['set_3'], [])).toEqual(['set_3']); + + }); + }); diff --git a/src/utils/settingsValidation/index.ts b/src/utils/settingsValidation/index.ts index 700ada43..8affa738 100644 --- a/src/utils/settingsValidation/index.ts +++ b/src/utils/settingsValidation/index.ts @@ -202,7 +202,7 @@ export function settingsValidation(config: unknown, validationParams: ISettingsV } // validate the `splitFilters` settings and parse splits query - const splitFiltersValidation = validateSplitFilters(log, withDefaults.sync.splitFilters, withDefaults.mode); + const splitFiltersValidation = validateSplitFilters(log, withDefaults.sync.splitFilters); withDefaults.sync.splitFilters = splitFiltersValidation.validFilters; withDefaults.sync.__splitFiltersValidation = splitFiltersValidation; diff --git a/src/utils/settingsValidation/splitFilters.ts b/src/utils/settingsValidation/splitFilters.ts index eb7b0371..023c3fc1 100644 --- a/src/utils/settingsValidation/splitFilters.ts +++ b/src/utils/settingsValidation/splitFilters.ts @@ -1,13 +1,19 @@ -import { STANDALONE_MODE } from '../constants'; import { validateSplits } from '../inputValidation/splits'; import { ISplitFiltersValidation } from '../../dtos/types'; import { SplitIO } from '../../types'; import { ILogger } from '../../logger/types'; -import { WARN_SPLITS_FILTER_IGNORED, WARN_SPLITS_FILTER_EMPTY, WARN_SPLITS_FILTER_INVALID, SETTINGS_SPLITS_FILTER, LOG_PREFIX_SETTINGS } from '../../logger/constants'; +import { WARN_SPLITS_FILTER_EMPTY, WARN_SPLITS_FILTER_INVALID, SETTINGS_SPLITS_FILTER, LOG_PREFIX_SETTINGS, ERROR_SETS_FILTER_EXCLUSIVE, WARN_SPLITS_FILTER_LOWERCASE_SET, WARN_SPLITS_FILTER_INVALID_SET, WARN_FLAGSET_NOT_CONFIGURED } from '../../logger/constants'; +import { objectAssign } from '../lang/objectAssign'; +import { find, uniq } from '../lang'; // Split filters metadata. // Ordered according to their precedency when forming the filter query string: `&names=&prefixes=` const FILTERS_METADATA = [ + { + type: 'bySet' as SplitIO.SplitFilterType, + maxLength: 50, + queryParam: 'sets=' + }, { type: 'byName' as SplitIO.SplitFilterType, maxLength: 400, @@ -20,6 +26,9 @@ const FILTERS_METADATA = [ } ]; +const VALID_FLAGSET_REGEX = /^[a-z0-9][_a-z0-9]{0,49}$/; +const CAPITAL_LETTERS_REGEX = /[A-Z]/; + /** * Validates that the given value is a valid filter type */ @@ -42,6 +51,11 @@ function validateSplitFilter(log: ILogger, type: SplitIO.SplitFilterType, values let result = validateSplits(log, values, LOG_PREFIX_SETTINGS, `${type} filter`, `${type} filter value`); if (result) { + + if (type === 'bySet') { + result = sanitizeFlagSets(log, result); + } + // check max length if (result.length > maxLength) throw new Error(`${maxLength} unique values can be specified at most for '${type}' filter. You passed ${result.length}. Please consider reducing the amount or using other filter.`); @@ -72,12 +86,48 @@ function queryStringBuilder(groupedFilters: Record 0 ? '&' + queryParams.join('&') : null; } +/** + * Sanitizes set names list taking in account: + * - It should be lowercase + * - Must adhere the following regular expression /^[a-z0-9][_a-z0-9]{0,49}$/ that means + * - must start with a letter or number + * - Be in lowercase + * - Be alphanumeric + * - have a max length of 50 characters + * + * @param {ILogger} log + * @param {string[]} flagSets + * @returns sanitized list of set names + */ +function sanitizeFlagSets(log: ILogger, flagSets: string[]) { + let sanitizedSets = flagSets + .map(flagSet => { + if (CAPITAL_LETTERS_REGEX.test(flagSet)){ + log.warn(WARN_SPLITS_FILTER_LOWERCASE_SET,[flagSet]); + flagSet = flagSet.toLowerCase(); + } + return flagSet; + }) + .filter(flagSet => { + if (!VALID_FLAGSET_REGEX.test(flagSet)){ + log.warn(WARN_SPLITS_FILTER_INVALID_SET, [flagSet,VALID_FLAGSET_REGEX,flagSet]); + return false; + } + if (typeof flagSet !== 'string') return false; + return true; + }); + return uniq(sanitizedSets); +} + +function configuredFilter(validFilters: SplitIO.SplitFilter[], filterType: SplitIO.SplitFilterType) { + return find(validFilters, (filter: SplitIO.SplitFilter) => filter.type === filterType && filter.values.length > 0); +} + /** * Validates `splitFilters` configuration object and parses it into a query string for filtering splits on `/splitChanges` fetch. * * @param {ILogger} log logger * @param {any} maybeSplitFilters split filters configuration param provided by the user - * @param {string} mode settings mode * @returns it returns an object with the following properties: * - `validFilters`: the validated `splitFilters` configuration object defined by the user. * - `queryString`: the parsed split filter query. it is null if all filters are invalid or all values in filters are invalid. @@ -85,21 +135,16 @@ function queryStringBuilder(groupedFilters: Record 0) res.groupedFilters[type] = validateSplitFilter(log, type, res.groupedFilters[type], maxLength); }); + const setFilter = configuredFilter(res.validFilters, 'bySet'); + // Clean all filters if set filter is present + if (setFilter) { + if (configuredFilter(res.validFilters, 'byName') || configuredFilter(res.validFilters, 'byPrefix')) log.error(ERROR_SETS_FILTER_EXCLUSIVE); + objectAssign(res.groupedFilters, { byName: [], byPrefix: [] }); + } + // build query string res.queryString = queryStringBuilder(res.groupedFilters); log.debug(SETTINGS_SPLITS_FILTER, [res.queryString]); return res; } + +export function flagSetsAreValid(log: ILogger, method: string, flagSets: string[], flagSetsInConfig: string[]): string[] { + const sets = validateSplits(log, flagSets, method, 'flag sets', 'flag set'); + let toReturn = sets ? sanitizeFlagSets(log, sets) : []; + if (flagSetsInConfig.length > 0) { + toReturn = toReturn.filter(flagSet => { + if (flagSetsInConfig.indexOf(flagSet) > -1) { + return true; + } + log.warn(WARN_FLAGSET_NOT_CONFIGURED, [method, flagSet]); + return false; + }); + } + + return toReturn; +}