Skip to content

Commit

Permalink
Merge pull request #263 from splitio/sdks-7437
Browse files Browse the repository at this point in the history
Flag sets
  • Loading branch information
emmaz90 authored Nov 3, 2023
2 parents 94fc1cc + 8ae6640 commit 50cd611
Show file tree
Hide file tree
Showing 54 changed files with 1,312 additions and 129 deletions.
10 changes: 10 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
75 changes: 61 additions & 14 deletions src/__tests__/mocks/fetchSpecificSplits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[][] = [
Expand Down Expand Up @@ -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: []
},
];
5 changes: 3 additions & 2 deletions src/dtos/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,8 @@ export interface ISplit {
trafficAllocationSeed?: number
configurations?: {
[treatmentName: string]: string
}
},
sets?: string[]
}

// Split definition used in offline mode
Expand Down Expand Up @@ -208,5 +209,5 @@ export interface IMetadata {
export type ISplitFiltersValidation = {
queryString: string | null,
groupedFilters: Record<SplitIO.SplitFilterType, string[]>,
validFilters: SplitIO.SplitFilter[]
validFilters: SplitIO.SplitFilter[],
};
73 changes: 72 additions & 1 deletion src/evaluator/__tests__/evaluate-features.spec.ts
Original file line number Diff line number Diff line change
@@ -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' }] },
Expand 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) {
Expand All @@ -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;
}
}
};
Expand Down Expand Up @@ -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);

});
27 changes: 27 additions & 0 deletions src/evaluator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Record<string, IEvaluationResult>> {
let storedFlagNames: MaybeThenable<ISet<string>>;

// 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,
Expand Down
4 changes: 2 additions & 2 deletions src/evaluator/matchers/__tests__/dependency.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ISplit> = {
'always-on': ALWAYS_ON_SPLIT,
Expand Down
6 changes: 5 additions & 1 deletion src/logger/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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';
Expand Down
2 changes: 2 additions & 0 deletions src/logger/messages/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'],
];
Loading

0 comments on commit 50cd611

Please sign in to comment.