Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
2.2.0 (March 26, 2025)
- Added new optional argument to the client `getTreatment` methods to allow passing additional evaluation options, such as a map of properties to append to the generated impression object sent to Split's backend. Read more in our docs.

2.1.0 (January 17, 2025)
- Added support for the new impressions tracking toggle available on feature flags, both respecting the setting and including the new field being returned on `SplitView` type objects. Read more in our docs.

Expand Down
307 changes: 72 additions & 235 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/logger/messages/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const codesError: [number, string][] = [
// input validation
[c.ERROR_EVENT_TYPE_FORMAT, '%s: you passed "%s", event_type must adhere to the regular expression /^[a-zA-Z0-9][-_.:a-zA-Z0-9]{0,79}$/g. This means an event_type must be alphanumeric, cannot be more than 80 characters long, and can only include a dash, underscore, period, or colon as separators of alphanumeric characters.'],
[c.ERROR_NOT_PLAIN_OBJECT, '%s: %s must be a plain object.'],
[c.ERROR_SIZE_EXCEEDED, '%s: the maximum size allowed for the properties is 32768 bytes, which was exceeded. Event not queued.'],
[c.ERROR_SIZE_EXCEEDED, '%s: the maximum size allowed for the properties is 32768 bytes, which was exceeded.'],
[c.ERROR_NOT_FINITE, '%s: value must be a finite number.'],
[c.ERROR_NULL, '%s: you passed a null or undefined %s. It must be a non-empty string.'],
[c.ERROR_TOO_LONG, '%s: %s too long. It must have 250 characters or less.'],
Expand Down
2 changes: 1 addition & 1 deletion src/logger/messages/warn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const codesWarn: [number, string][] = codesError.concat([
[c.CLIENT_NO_LISTENER, 'No listeners for SDK Readiness detected. Incorrect control treatments could have been logged if you called getTreatment/s while the SDK was not yet ready.'],
// input validation
[c.WARN_SETTING_NULL, '%s: Property "%s" is of invalid type. Setting value to null.'],
[c.WARN_TRIMMING_PROPERTIES, '%s: Event has more than 300 properties. Some of them will be trimmed when processed.'],
[c.WARN_TRIMMING_PROPERTIES, '%s: more than 300 properties were provided. Some of them will be trimmed when processed.'],
[c.WARN_CONVERTING, '%s: %s "%s" is not of type string, converting.'],
[c.WARN_TRIMMING, '%s: %s "%s" has extra whitespace, trimming.'],
[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.'],
Expand Down
189 changes: 19 additions & 170 deletions src/sdkClient/__tests__/clientAttributesDecoration.spec.ts

Large diffs are not rendered by default.

44 changes: 43 additions & 1 deletion src/sdkClient/__tests__/clientInputValidation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import { clientInputValidationDecorator } from '../clientInputValidation';

// Mocks
import { DebugLogger } from '../../logger/browser/DebugLogger';
import { createClientMock } from './testUtils';

const settings: any = {
log: DebugLogger(),
sync: { __splitFiltersValidation: { groupedFilters: { bySet: [] } } }
};

const client: any = {};
const EVALUATION_RESULT = 'on';
const client: any = createClientMock(EVALUATION_RESULT);

const readinessManager: any = {
isReady: () => true,
Expand Down Expand Up @@ -52,4 +54,44 @@ describe('clientInputValidationDecorator', () => {
// @TODO should be 8, but there is an additional log from `getTreatmentsByFlagSet` and `getTreatmentsWithConfigByFlagSet` that should be removed
expect(logSpy).toBeCalledTimes(10);
});

test('should evaluate but log an error if the passed 4th argument (evaluation options) is invalid', () => {
expect(clientWithValidation.getTreatment('key', 'ff', undefined, 'invalid')).toBe(EVALUATION_RESULT);
expect(logSpy).toHaveBeenLastCalledWith('[ERROR] splitio => getTreatment: evaluation options must be a plain object.');
expect(client.getTreatment).toBeCalledWith('key', 'ff', undefined, undefined);

expect(clientWithValidation.getTreatmentWithConfig('key', 'ff', undefined, { properties: 'invalid' })).toBe(EVALUATION_RESULT);
expect(logSpy).toHaveBeenLastCalledWith('[ERROR] splitio => getTreatmentWithConfig: properties must be a plain object.');
expect(client.getTreatmentWithConfig).toBeCalledWith('key', 'ff', undefined, undefined);

expect(clientWithValidation.getTreatments('key', ['ff'], undefined, { properties: 'invalid' })).toBe(EVALUATION_RESULT);
expect(logSpy).toHaveBeenLastCalledWith('[ERROR] splitio => getTreatments: properties must be a plain object.');
expect(client.getTreatments).toBeCalledWith('key', ['ff'], undefined, undefined);

expect(clientWithValidation.getTreatmentsWithConfig('key', ['ff'], {}, { properties: true })).toBe(EVALUATION_RESULT);
expect(logSpy).toHaveBeenLastCalledWith('[ERROR] splitio => getTreatmentsWithConfig: properties must be a plain object.');
expect(client.getTreatmentsWithConfig).toBeCalledWith('key', ['ff'], {}, undefined);

expect(clientWithValidation.getTreatmentsByFlagSet('key', 'flagSet', undefined, { properties: 'invalid' })).toBe(EVALUATION_RESULT);
expect(logSpy).toHaveBeenLastCalledWith('[ERROR] splitio => getTreatmentsByFlagSet: properties must be a plain object.');
expect(client.getTreatmentsByFlagSet).toBeCalledWith('key', 'flagset', undefined, undefined);

expect(clientWithValidation.getTreatmentsWithConfigByFlagSet('key', 'flagSet', {}, { properties: 'invalid' })).toBe(EVALUATION_RESULT);
expect(logSpy).toBeCalledWith('[ERROR] splitio => getTreatmentsWithConfigByFlagSet: properties must be a plain object.');
expect(client.getTreatmentsWithConfigByFlagSet).toBeCalledWith('key', 'flagset', {}, undefined);

expect(clientWithValidation.getTreatmentsByFlagSets('key', ['flagSet'], undefined, { properties: 'invalid' })).toBe(EVALUATION_RESULT);
expect(logSpy).toHaveBeenLastCalledWith('[ERROR] splitio => getTreatmentsByFlagSets: properties must be a plain object.');
expect(client.getTreatmentsByFlagSets).toBeCalledWith('key', ['flagset'], undefined, undefined);

expect(clientWithValidation.getTreatmentsWithConfigByFlagSets('key', ['flagSet'], {}, { properties: 'invalid' })).toBe(EVALUATION_RESULT);
expect(logSpy).toHaveBeenLastCalledWith('[ERROR] splitio => getTreatmentsWithConfigByFlagSets: properties must be a plain object.');
expect(client.getTreatmentsWithConfigByFlagSets).toBeCalledWith('key', ['flagset'], {}, undefined);
});

test('should sanitize the properties in the 4th argument', () => {
expect(clientWithValidation.getTreatment('key', 'ff', undefined, { properties: { toSanitize: /asd/, correct: 100 }})).toBe(EVALUATION_RESULT);
expect(logSpy).toHaveBeenLastCalledWith('[WARN] splitio => getTreatment: Property "toSanitize" is of invalid type. Setting value to null.');
expect(client.getTreatment).toBeCalledWith('key', 'ff', undefined, { properties: { toSanitize: null, correct: 100 }});
});
});
15 changes: 15 additions & 0 deletions src/sdkClient/__tests__/testUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,18 @@ export function assertClientApi(client: any, sdkStatus?: object) {
expect(typeof client[method]).toBe('function');
});
}

export function createClientMock(returnValue: any) {

return {
getTreatment: jest.fn(()=> returnValue),
getTreatmentWithConfig: jest.fn(()=> returnValue),
getTreatments: jest.fn(()=> returnValue),
getTreatmentsWithConfig: jest.fn(()=> returnValue),
getTreatmentsByFlagSets: jest.fn(()=> returnValue),
getTreatmentsWithConfigByFlagSets: jest.fn(()=> returnValue),
getTreatmentsByFlagSet: jest.fn(()=> returnValue),
getTreatmentsWithConfigByFlagSet: jest.fn(()=> returnValue),
track: jest.fn(()=> returnValue),
};
}
52 changes: 31 additions & 21 deletions src/sdkClient/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ function treatmentsNotReady(featureFlagNames: string[]) {
return evaluations;
}

function stringify(options?: SplitIO.EvaluationOptions) {
if (options && options.properties) {
try {
return JSON.stringify(options.properties);
} catch { /* JSON.stringify should never throw with validated options, but handling just in case */ }
}
}

/**
* Creator of base client with getTreatments and track methods.
*/
Expand All @@ -31,12 +39,12 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl
const { log, mode } = settings;
const isAsync = isConsumerMode(mode);

function getTreatment(key: SplitIO.SplitKey, featureFlagName: string, attributes: SplitIO.Attributes | undefined, withConfig = false, methodName = GET_TREATMENT) {
function getTreatment(key: SplitIO.SplitKey, featureFlagName: string, attributes?: SplitIO.Attributes, options?: SplitIO.EvaluationOptions, withConfig = false, methodName = GET_TREATMENT) {
const stopTelemetryTracker = telemetryTracker.trackEval(withConfig ? TREATMENT_WITH_CONFIG : TREATMENT);

const wrapUp = (evaluationResult: IEvaluationResult) => {
const queue: ImpressionDecorated[] = [];
const treatment = processEvaluation(evaluationResult, featureFlagName, key, attributes, withConfig, methodName, queue);
const treatment = processEvaluation(evaluationResult, featureFlagName, key, stringify(options), withConfig, methodName, queue);
impressionsTracker.track(queue, attributes);

stopTelemetryTracker(queue[0] && queue[0].imp.label);
Expand All @@ -52,18 +60,19 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl
return thenable(evaluation) ? evaluation.then((res) => wrapUp(res)) : wrapUp(evaluation);
}

function getTreatmentWithConfig(key: SplitIO.SplitKey, featureFlagName: string, attributes: SplitIO.Attributes | undefined) {
return getTreatment(key, featureFlagName, attributes, true, GET_TREATMENT_WITH_CONFIG);
function getTreatmentWithConfig(key: SplitIO.SplitKey, featureFlagName: string, attributes?: SplitIO.Attributes, options?: SplitIO.EvaluationOptions) {
return getTreatment(key, featureFlagName, attributes, options, true, GET_TREATMENT_WITH_CONFIG);
}

function getTreatments(key: SplitIO.SplitKey, featureFlagNames: string[], attributes: SplitIO.Attributes | undefined, withConfig = false, methodName = GET_TREATMENTS) {
function getTreatments(key: SplitIO.SplitKey, featureFlagNames: string[], attributes?: SplitIO.Attributes, options?: SplitIO.EvaluationOptions, withConfig = false, methodName = GET_TREATMENTS) {
const stopTelemetryTracker = telemetryTracker.trackEval(withConfig ? TREATMENTS_WITH_CONFIG : TREATMENTS);

const wrapUp = (evaluationResults: Record<string, IEvaluationResult>) => {
const queue: ImpressionDecorated[] = [];
const treatments: Record<string, SplitIO.Treatment | SplitIO.TreatmentWithConfig> = {};
const treatments: SplitIO.Treatments | SplitIO.TreatmentsWithConfig = {};
const properties = stringify(options);
Object.keys(evaluationResults).forEach(featureFlagName => {
treatments[featureFlagName] = processEvaluation(evaluationResults[featureFlagName], featureFlagName, key, attributes, withConfig, methodName, queue);
treatments[featureFlagName] = processEvaluation(evaluationResults[featureFlagName], featureFlagName, key, properties, withConfig, methodName, queue);
});
impressionsTracker.track(queue, attributes);

Expand All @@ -80,19 +89,19 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl
return thenable(evaluations) ? evaluations.then((res) => wrapUp(res)) : wrapUp(evaluations);
}

function getTreatmentsWithConfig(key: SplitIO.SplitKey, featureFlagNames: string[], attributes: SplitIO.Attributes | undefined) {
return getTreatments(key, featureFlagNames, attributes, true, GET_TREATMENTS_WITH_CONFIG);
function getTreatmentsWithConfig(key: SplitIO.SplitKey, featureFlagNames: string[], attributes?: SplitIO.Attributes, options?: SplitIO.EvaluationOptions) {
return getTreatments(key, featureFlagNames, attributes, options, true, GET_TREATMENTS_WITH_CONFIG);
}

function getTreatmentsByFlagSets(key: SplitIO.SplitKey, flagSetNames: string[], attributes: SplitIO.Attributes | undefined, withConfig = false, method: Method = TREATMENTS_BY_FLAGSETS, methodName = GET_TREATMENTS_BY_FLAG_SETS) {
function getTreatmentsByFlagSets(key: SplitIO.SplitKey, flagSetNames: string[], attributes?: SplitIO.Attributes, options?: SplitIO.EvaluationOptions, withConfig = false, method: Method = TREATMENTS_BY_FLAGSETS, methodName = GET_TREATMENTS_BY_FLAG_SETS) {
const stopTelemetryTracker = telemetryTracker.trackEval(method);

const wrapUp = (evaluationResults: Record<string, IEvaluationResult>) => {
const queue: ImpressionDecorated[] = [];
const treatments: Record<string, SplitIO.Treatment | SplitIO.TreatmentWithConfig> = {};
const evaluations = evaluationResults;
Object.keys(evaluations).forEach(featureFlagName => {
treatments[featureFlagName] = processEvaluation(evaluations[featureFlagName], featureFlagName, key, attributes, withConfig, methodName, queue);
const treatments: SplitIO.Treatments | SplitIO.TreatmentsWithConfig = {};
const properties = stringify(options);
Object.keys(evaluationResults).forEach(featureFlagName => {
treatments[featureFlagName] = processEvaluation(evaluationResults[featureFlagName], featureFlagName, key, properties, withConfig, methodName, queue);
});
impressionsTracker.track(queue, attributes);

Expand All @@ -109,24 +118,24 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl
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, GET_TREATMENTS_WITH_CONFIG_BY_FLAG_SETS);
function getTreatmentsWithConfigByFlagSets(key: SplitIO.SplitKey, flagSetNames: string[], attributes?: SplitIO.Attributes, options?: SplitIO.EvaluationOptions) {
return getTreatmentsByFlagSets(key, flagSetNames, attributes, options, true, TREATMENTS_WITH_CONFIG_BY_FLAGSETS, GET_TREATMENTS_WITH_CONFIG_BY_FLAG_SETS);
}

function getTreatmentsByFlagSet(key: SplitIO.SplitKey, flagSetName: string, attributes: SplitIO.Attributes | undefined) {
return getTreatmentsByFlagSets(key, [flagSetName], attributes, false, TREATMENTS_BY_FLAGSET, GET_TREATMENTS_BY_FLAG_SET);
function getTreatmentsByFlagSet(key: SplitIO.SplitKey, flagSetName: string, attributes?: SplitIO.Attributes, options?: SplitIO.EvaluationOptions) {
return getTreatmentsByFlagSets(key, [flagSetName], attributes, options, false, TREATMENTS_BY_FLAGSET, GET_TREATMENTS_BY_FLAG_SET);
}

function getTreatmentsWithConfigByFlagSet(key: SplitIO.SplitKey, flagSetName: string, attributes: SplitIO.Attributes | undefined) {
return getTreatmentsByFlagSets(key, [flagSetName], attributes, true, TREATMENTS_WITH_CONFIG_BY_FLAGSET, GET_TREATMENTS_WITH_CONFIG_BY_FLAG_SET);
function getTreatmentsWithConfigByFlagSet(key: SplitIO.SplitKey, flagSetName: string, attributes?: SplitIO.Attributes, options?: SplitIO.EvaluationOptions) {
return getTreatmentsByFlagSets(key, [flagSetName], attributes, options, true, TREATMENTS_WITH_CONFIG_BY_FLAGSET, GET_TREATMENTS_WITH_CONFIG_BY_FLAG_SET);
}

// Internal function
function processEvaluation(
evaluation: IEvaluationResult,
featureFlagName: string,
key: SplitIO.SplitKey,
attributes: SplitIO.Attributes | undefined,
properties: string | undefined,
withConfig: boolean,
invokingMethodName: string,
queue: ImpressionDecorated[]
Expand All @@ -148,6 +157,7 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl
bucketingKey,
label,
changeNumber: changeNumber as number,
properties
},
disabled: impressionsDisabled
});
Expand Down
Loading