From 850b0748956e4765c67d9a451056b4ea3605652d Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Thu, 9 Oct 2025 16:04:14 -0300 Subject: [PATCH 1/9] Implement FallbackSanitizer and add fallback to config --- .../__tests__/fallback-sanitizer.spec.ts | 103 ++++++++++++++++++ .../fallbackTreatmentsCalculator/constants.ts | 4 + .../fallbackSanitizer/index.ts | 63 +++++++++++ types/splitio.d.ts | 13 +++ 4 files changed, 183 insertions(+) create mode 100644 src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts create mode 100644 src/evaluator/fallbackTreatmentsCalculator/constants.ts create mode 100644 src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts diff --git a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts new file mode 100644 index 00000000..e063a570 --- /dev/null +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts @@ -0,0 +1,103 @@ +import { FallbacksSanitizer } from '../fallbackSanitizer'; +import { TreatmentWithConfig } from '../../../../types/splitio'; + +describe('FallbacksSanitizer', () => { + const validTreatment: TreatmentWithConfig = { treatment: 'on', config: '{"color":"blue"}' }; + const invalidTreatment: TreatmentWithConfig = { treatment: ' ', config: null }; + + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + (console.error as jest.Mock).mockRestore(); + }); + + describe('isValidFlagName', () => { + it('returns true for a valid flag name', () => { + // @ts-expect-private-access + expect((FallbacksSanitizer as any).isValidFlagName('my_flag')).toBe(true); + }); + + it('returns false for a name longer than 100 chars', () => { + const longName = 'a'.repeat(101); + expect((FallbacksSanitizer as any).isValidFlagName(longName)).toBe(false); + }); + + it('returns false if the name contains spaces', () => { + expect((FallbacksSanitizer as any).isValidFlagName('invalid flag')).toBe(false); + }); + }); + + describe('isValidTreatment', () => { + it('returns true for a valid treatment string', () => { + expect((FallbacksSanitizer as any).isValidTreatment(validTreatment)).toBe(true); + }); + + it('returns false for null or undefined', () => { + expect((FallbacksSanitizer as any).isValidTreatment(null)).toBe(false); + expect((FallbacksSanitizer as any).isValidTreatment(undefined)).toBe(false); + }); + + it('returns false for a treatment longer than 100 chars', () => { + const long = { treatment: 'a'.repeat(101) }; + expect((FallbacksSanitizer as any).isValidTreatment(long)).toBe(false); + }); + + it('returns false if treatment does not match regex pattern', () => { + const invalid = { treatment: 'invalid treatment!' }; + expect((FallbacksSanitizer as any).isValidTreatment(invalid)).toBe(false); + }); + }); + + describe('sanitizeGlobal', () => { + it('returns the treatment if valid', () => { + expect(FallbacksSanitizer.sanitizeGlobal(validTreatment)).toEqual(validTreatment); + expect(console.error).not.toHaveBeenCalled(); + }); + + it('returns undefined and logs error if invalid', () => { + const result = FallbacksSanitizer.sanitizeGlobal(invalidTreatment); + expect(result).toBeUndefined(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Fallback treatments - Discarded fallback') + ); + }); + }); + + describe('sanitizeByFlag', () => { + it('returns a sanitized map with valid entries only', () => { + const input = { + valid_flag: validTreatment, + 'invalid flag': validTreatment, + bad_treatment: invalidTreatment, + }; + + const result = FallbacksSanitizer.sanitizeByFlag(input); + + expect(result).toEqual({ valid_flag: validTreatment }); + expect(console.error).toHaveBeenCalledTimes(2); // invalid flag + bad_treatment + }); + + it('returns empty object if all invalid', () => { + const input = { + 'invalid flag': invalidTreatment, + }; + + const result = FallbacksSanitizer.sanitizeByFlag(input); + expect(result).toEqual({}); + expect(console.error).toHaveBeenCalled(); + }); + + it('returns same object if all valid', () => { + const input = { + flag_one: validTreatment, + flag_two: { treatment: 'valid_2', config: null }, + }; + + const result = FallbacksSanitizer.sanitizeByFlag(input); + expect(result).toEqual(input); + expect(console.error).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/evaluator/fallbackTreatmentsCalculator/constants.ts b/src/evaluator/fallbackTreatmentsCalculator/constants.ts new file mode 100644 index 00000000..292bae0f --- /dev/null +++ b/src/evaluator/fallbackTreatmentsCalculator/constants.ts @@ -0,0 +1,4 @@ +export enum FallbackDiscardReason { + FlagName = 'Invalid flag name (max 100 chars, no spaces)', + Treatment = 'Invalid treatment (max 100 chars and must match pattern)', +} diff --git a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts new file mode 100644 index 00000000..a8e07e34 --- /dev/null +++ b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts @@ -0,0 +1,63 @@ +import { TreatmentWithConfig } from '../../../../types/splitio'; +import { FallbackDiscardReason } from '../constants'; + + +export class FallbacksSanitizer { + + private static readonly pattern = /^[0-9]+[.a-zA-Z0-9_-]*$|^[a-zA-Z]+[a-zA-Z0-9_-]*$/; + + private static isValidFlagName(name: string): boolean { + return name.length <= 100 && !name.includes(' '); + } + + private static isValidTreatment(t?: TreatmentWithConfig): boolean { + if (!t || !t.treatment) { + return false; + } + + const { treatment } = t; + + if (treatment.length > 100) { + return false; + } + + return FallbacksSanitizer.pattern.test(treatment); + } + + static sanitizeGlobal(treatment?: TreatmentWithConfig): TreatmentWithConfig | undefined { + if (!this.isValidTreatment(treatment)) { + console.error( + `Fallback treatments - Discarded fallback: ${FallbackDiscardReason.Treatment}` + ); + return undefined; + } + return treatment!; + } + + static sanitizeByFlag( + byFlagFallbacks: Record + ): Record { + const sanitizedByFlag: Record = {}; + + const entries = Object.entries(byFlagFallbacks); + entries.forEach(([flag, t]) => { + if (!this.isValidFlagName(flag)) { + console.error( + `Fallback treatments - Discarded flag '${flag}': ${FallbackDiscardReason.FlagName}` + ); + return; + } + + if (!this.isValidTreatment(t)) { + console.error( + `Fallback treatments - Discarded treatment for flag '${flag}': ${FallbackDiscardReason.Treatment}` + ); + return; + } + + sanitizedByFlag[flag] = t; + }); + + return sanitizedByFlag; + } +} diff --git a/types/splitio.d.ts b/types/splitio.d.ts index ad8644b2..510adfbf 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -579,6 +579,10 @@ declare namespace SplitIO { * User consent status if using in client-side. Undefined if using in server-side (Node.js). */ readonly userConsent?: ConsentStatus; + /** + * Fallback treatments to be used when the SDK is not ready or the flag is not found. + */ + readonly fallbackTreatments?: FallbackTreatmentOptions; } /** * Log levels. @@ -1142,6 +1146,15 @@ declare namespace SplitIO { * User consent status. */ type ConsentStatus = 'GRANTED' | 'DECLINED' | 'UNKNOWN'; + /** + * Fallback treatments to be used when the SDK is not ready or the flag is not found. + */ + type FallbackTreatmentOptions = { + global?: TreatmentWithConfig | Treatment, + byFlag: { + [key: string]: TreatmentWithConfig | Treatment + } + } /** * Logger. Its interface details are not part of the public API. It shouldn't be used directly. */ From 1a05cee975be123af2dd1a2093bd303e978b20b2 Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Thu, 16 Oct 2025 14:52:25 -0300 Subject: [PATCH 2/9] [FME-10566] Create FallbackTreatmentsCalculator --- .../__tests__/fallback-calculator.spec.ts | 66 +++++++++++++++++++ .../__tests__/fallback-sanitizer.spec.ts | 24 +++---- .../fallbackSanitizer/index.ts | 25 ++++--- .../fallbackTreatmentsCalculator/index.ts | 49 ++++++++++++++ types/splitio.d.ts | 19 ++++-- 5 files changed, 157 insertions(+), 26 deletions(-) create mode 100644 src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts create mode 100644 src/evaluator/fallbackTreatmentsCalculator/index.ts diff --git a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts new file mode 100644 index 00000000..786ecbe8 --- /dev/null +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts @@ -0,0 +1,66 @@ +import { FallbackTreatmentsCalculator } from '../'; +import type { FallbackTreatmentConfiguration } from '../../../../types/splitio'; // adjust path if needed + +describe('FallbackTreatmentsCalculator', () => { + + test('returns specific fallback if flag exists', () => { + const config: FallbackTreatmentConfiguration = { + byFlag: { + 'featureA': { treatment: 'TREATMENT_A', config: '{ value: 1 }' }, + }, + }; + const calculator = new FallbackTreatmentsCalculator(config); + const result = calculator.resolve('featureA', 'label by flag'); + + expect(result).toEqual({ + treatment: 'TREATMENT_A', + config: '{ value: 1 }', + label: 'fallback - label by flag', + }); + }); + + test('returns global fallback if flag is missing and global exists', () => { + const config: FallbackTreatmentConfiguration = { + byFlag: {}, + global: { treatment: 'GLOBAL_TREATMENT', config: '{ global: true }' }, + }; + const calculator = new FallbackTreatmentsCalculator(config); + const result = calculator.resolve('missingFlag', 'label by global'); + + expect(result).toEqual({ + treatment: 'GLOBAL_TREATMENT', + config: '{ global: true }', + label: 'fallback - label by global', + }); + }); + + test('returns control fallback if flag and global are missing', () => { + const config: FallbackTreatmentConfiguration = { + byFlag: {}, + }; + const calculator = new FallbackTreatmentsCalculator(config); + const result = calculator.resolve('missingFlag', 'label by noFallback'); + + expect(result).toEqual({ + treatment: 'CONTROL', + config: null, + label: 'fallback - label by noFallback', + }); + }); + + test('returns undefined label if no label provided', () => { + const config: FallbackTreatmentConfiguration = { + byFlag: { + 'featureB': { treatment: 'TREATMENT_B' }, + }, + }; + const calculator = new FallbackTreatmentsCalculator(config); + const result = calculator.resolve('featureB'); + + expect(result).toEqual({ + treatment: 'TREATMENT_B', + config: undefined, + label: undefined, + }); + }); +}); diff --git a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts index e063a570..0f314e2a 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts @@ -14,49 +14,49 @@ describe('FallbacksSanitizer', () => { }); describe('isValidFlagName', () => { - it('returns true for a valid flag name', () => { + test('returns true for a valid flag name', () => { // @ts-expect-private-access expect((FallbacksSanitizer as any).isValidFlagName('my_flag')).toBe(true); }); - it('returns false for a name longer than 100 chars', () => { + test('returns false for a name longer than 100 chars', () => { const longName = 'a'.repeat(101); expect((FallbacksSanitizer as any).isValidFlagName(longName)).toBe(false); }); - it('returns false if the name contains spaces', () => { + test('returns false if the name contains spaces', () => { expect((FallbacksSanitizer as any).isValidFlagName('invalid flag')).toBe(false); }); }); describe('isValidTreatment', () => { - it('returns true for a valid treatment string', () => { + test('returns true for a valid treatment string', () => { expect((FallbacksSanitizer as any).isValidTreatment(validTreatment)).toBe(true); }); - it('returns false for null or undefined', () => { + test('returns false for null or undefined', () => { expect((FallbacksSanitizer as any).isValidTreatment(null)).toBe(false); expect((FallbacksSanitizer as any).isValidTreatment(undefined)).toBe(false); }); - it('returns false for a treatment longer than 100 chars', () => { + test('returns false for a treatment longer than 100 chars', () => { const long = { treatment: 'a'.repeat(101) }; expect((FallbacksSanitizer as any).isValidTreatment(long)).toBe(false); }); - it('returns false if treatment does not match regex pattern', () => { + test('returns false if treatment does not match regex pattern', () => { const invalid = { treatment: 'invalid treatment!' }; expect((FallbacksSanitizer as any).isValidTreatment(invalid)).toBe(false); }); }); describe('sanitizeGlobal', () => { - it('returns the treatment if valid', () => { + test('returns the treatment if valid', () => { expect(FallbacksSanitizer.sanitizeGlobal(validTreatment)).toEqual(validTreatment); expect(console.error).not.toHaveBeenCalled(); }); - it('returns undefined and logs error if invalid', () => { + test('returns undefined and logs error if invalid', () => { const result = FallbacksSanitizer.sanitizeGlobal(invalidTreatment); expect(result).toBeUndefined(); expect(console.error).toHaveBeenCalledWith( @@ -66,7 +66,7 @@ describe('FallbacksSanitizer', () => { }); describe('sanitizeByFlag', () => { - it('returns a sanitized map with valid entries only', () => { + test('returns a sanitized map with valid entries only', () => { const input = { valid_flag: validTreatment, 'invalid flag': validTreatment, @@ -79,7 +79,7 @@ describe('FallbacksSanitizer', () => { expect(console.error).toHaveBeenCalledTimes(2); // invalid flag + bad_treatment }); - it('returns empty object if all invalid', () => { + test('returns empty object if all invalid', () => { const input = { 'invalid flag': invalidTreatment, }; @@ -89,7 +89,7 @@ describe('FallbacksSanitizer', () => { expect(console.error).toHaveBeenCalled(); }); - it('returns same object if all valid', () => { + test('returns same object if all valid', () => { const input = { flag_one: validTreatment, flag_two: { treatment: 'valid_2', config: null }, diff --git a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts index a8e07e34..06c7fe2e 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts @@ -1,4 +1,4 @@ -import { TreatmentWithConfig } from '../../../../types/splitio'; +import { FallbackTreatment } from '../../../../types/splitio'; import { FallbackDiscardReason } from '../constants'; @@ -10,21 +10,26 @@ export class FallbacksSanitizer { return name.length <= 100 && !name.includes(' '); } - private static isValidTreatment(t?: TreatmentWithConfig): boolean { - if (!t || !t.treatment) { + private static isValidTreatment(t?: FallbackTreatment): boolean { + if (!t) { return false; } - const { treatment } = t; + if (typeof t === 'string') { + if (t.length > 100) { + return false; + } + return FallbacksSanitizer.pattern.test(t); + } - if (treatment.length > 100) { + const { treatment } = t; + if (!treatment || treatment.length > 100) { return false; } - return FallbacksSanitizer.pattern.test(treatment); } - static sanitizeGlobal(treatment?: TreatmentWithConfig): TreatmentWithConfig | undefined { + static sanitizeGlobal(treatment?: FallbackTreatment): FallbackTreatment | undefined { if (!this.isValidTreatment(treatment)) { console.error( `Fallback treatments - Discarded fallback: ${FallbackDiscardReason.Treatment}` @@ -35,9 +40,9 @@ export class FallbacksSanitizer { } static sanitizeByFlag( - byFlagFallbacks: Record - ): Record { - const sanitizedByFlag: Record = {}; + byFlagFallbacks: Record + ): Record { + const sanitizedByFlag: Record = {}; const entries = Object.entries(byFlagFallbacks); entries.forEach(([flag, t]) => { diff --git a/src/evaluator/fallbackTreatmentsCalculator/index.ts b/src/evaluator/fallbackTreatmentsCalculator/index.ts new file mode 100644 index 00000000..30ef69e2 --- /dev/null +++ b/src/evaluator/fallbackTreatmentsCalculator/index.ts @@ -0,0 +1,49 @@ +import { FallbackTreatmentConfiguration, FallbackTreatment, IFallbackTreatmentsCalculator} from '../../../types/splitio'; + +export class FallbackTreatmentsCalculator implements IFallbackTreatmentsCalculator { + private readonly labelPrefix = 'fallback - '; + private readonly control = 'CONTROL'; + private readonly fallbacks: FallbackTreatmentConfiguration; + + constructor(fallbacks: FallbackTreatmentConfiguration) { + this.fallbacks = fallbacks; + } + + resolve(flagName: string, label?: string | undefined): FallbackTreatment { + const treatment = this.fallbacks.byFlag[flagName]; + if (treatment) { + return this.copyWithLabel(treatment, label); + } + + if (this.fallbacks.global) { + return this.copyWithLabel(this.fallbacks.global, label); + } + + return { + treatment: this.control, + config: null, + label: this.resolveLabel(label), + }; + } + + private copyWithLabel(fallback: FallbackTreatment, label: string | undefined): FallbackTreatment { + if (typeof fallback === 'string') { + return { + treatment: fallback, + config: null, + label: this.resolveLabel(label), + }; + } + + return { + treatment: fallback.treatment, + config: fallback.config, + label: this.resolveLabel(label), + }; + } + + private resolveLabel(label?: string | undefined): string | undefined { + return label ? `${this.labelPrefix}${label}` : undefined; + } + +} diff --git a/types/splitio.d.ts b/types/splitio.d.ts index 788029d3..f1ac014f 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -624,7 +624,7 @@ declare namespace SplitIO { /** * Fallback treatments to be used when the SDK is not ready or the flag is not found. */ - readonly fallbackTreatments?: FallbackTreatmentOptions; + readonly fallbackTreatments?: FallbackTreatmentConfiguration; } /** * Log levels. @@ -1232,15 +1232,26 @@ declare namespace SplitIO { * User consent status. */ type ConsentStatus = 'GRANTED' | 'DECLINED' | 'UNKNOWN'; + /** + * Fallback treatment can be either a string (treatment) or an object with treatment, config and label. + */ + type FallbackTreatment = string | { + treatment: string; + config?: string | null; + label?: string | null; + }; /** * Fallback treatments to be used when the SDK is not ready or the flag is not found. */ - type FallbackTreatmentOptions = { - global?: TreatmentWithConfig | Treatment, + type FallbackTreatmentConfiguration = { + global?: FallbackTreatment, byFlag: { - [key: string]: TreatmentWithConfig | Treatment + [key: string]: FallbackTreatment } } + type IFallbackTreatmentsCalculator = { + resolve(flagName: string, label?: string | undefined): FallbackTreatment; + } /** * Logger. Its interface details are not part of the public API. It shouldn't be used directly. */ From 89111320ffdeddd45e1bd7a738c9e294bda60bdb Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Wed, 22 Oct 2025 16:43:38 -0300 Subject: [PATCH 3/9] [FME-10567] Add fallbackTreatmentCalculator to client --- .../__tests__/fallback-calculator.spec.ts | 106 ++++++++++++++++-- .../__tests__/fallback-sanitizer.spec.ts | 23 ++-- .../fallbackSanitizer/index.ts | 44 ++++---- .../fallbackTreatmentsCalculator/index.ts | 36 ++++-- .../__tests__/clientInputValidation.spec.ts | 5 +- .../__tests__/sdkClientMethod.spec.ts | 7 +- src/sdkClient/client.ts | 12 +- src/sdkClient/clientInputValidation.ts | 29 +++-- src/sdkClient/sdkClient.ts | 3 +- src/sdkFactory/__tests__/index.spec.ts | 2 + src/sdkFactory/index.ts | 5 +- src/sdkFactory/types.ts | 2 + types/splitio.d.ts | 17 +-- 13 files changed, 206 insertions(+), 85 deletions(-) diff --git a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts index 786ecbe8..4a1e9d93 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts @@ -1,7 +1,91 @@ import { FallbackTreatmentsCalculator } from '../'; -import type { FallbackTreatmentConfiguration } from '../../../../types/splitio'; // adjust path if needed +import type { FallbackTreatmentConfiguration } from '../../../../types/splitio'; +import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; +import { CONTROL } from '../../../utils/constants'; -describe('FallbackTreatmentsCalculator', () => { +describe('FallbackTreatmentsCalculator' , () => { + const longName = 'a'.repeat(101); + + test('logs an error if flag name is invalid - by Flag', () => { + let config: FallbackTreatmentConfiguration = { + byFlag: { + 'feature A': { treatment: 'TREATMENT_A', config: '{ value: 1 }' }, + }, + }; + new FallbackTreatmentsCalculator(loggerMock, config); + expect(loggerMock.error.mock.calls[0][0]).toBe( + 'Fallback treatments - Discarded flag \'feature A\': Invalid flag name (max 100 chars, no spaces)' + ); + config = { + byFlag: { + [longName]: { treatment: 'TREATMENT_A', config: '{ value: 1 }' }, + }, + }; + new FallbackTreatmentsCalculator(loggerMock, config); + expect(loggerMock.error.mock.calls[1][0]).toBe( + `Fallback treatments - Discarded flag '${longName}': Invalid flag name (max 100 chars, no spaces)` + ); + + config = { + byFlag: { + 'featureB': { treatment: longName, config: '{ value: 1 }' }, + }, + }; + new FallbackTreatmentsCalculator(loggerMock, config); + expect(loggerMock.error.mock.calls[2][0]).toBe( + 'Fallback treatments - Discarded treatment for flag \'featureB\': Invalid treatment (max 100 chars and must match pattern)' + ); + + config = { + byFlag: { + // @ts-ignore + 'featureC': { config: '{ global: true }' }, + }, + }; + new FallbackTreatmentsCalculator(loggerMock, config); + expect(loggerMock.error.mock.calls[3][0]).toBe( + 'Fallback treatments - Discarded treatment for flag \'featureC\': Invalid treatment (max 100 chars and must match pattern)' + ); + + config = { + byFlag: { + // @ts-ignore + 'featureC': { treatment: 'invalid treatment!', config: '{ global: true }' }, + }, + }; + new FallbackTreatmentsCalculator(loggerMock, config); + expect(loggerMock.error.mock.calls[4][0]).toBe( + 'Fallback treatments - Discarded treatment for flag \'featureC\': Invalid treatment (max 100 chars and must match pattern)' + ); + }); + + test('logs an error if flag name is invalid - global', () => { + let config: FallbackTreatmentConfiguration = { + global: { treatment: longName, config: '{ value: 1 }' }, + }; + new FallbackTreatmentsCalculator(loggerMock, config); + expect(loggerMock.error.mock.calls[2][0]).toBe( + 'Fallback treatments - Discarded treatment for flag \'featureB\': Invalid treatment (max 100 chars and must match pattern)' + ); + + config = { + // @ts-ignore + global: { config: '{ global: true }' }, + }; + new FallbackTreatmentsCalculator(loggerMock, config); + expect(loggerMock.error.mock.calls[3][0]).toBe( + 'Fallback treatments - Discarded treatment for flag \'featureC\': Invalid treatment (max 100 chars and must match pattern)' + ); + + config = { + // @ts-ignore + global: { treatment: 'invalid treatment!', config: '{ global: true }' }, + }; + new FallbackTreatmentsCalculator(loggerMock, config); + expect(loggerMock.error.mock.calls[4][0]).toBe( + 'Fallback treatments - Discarded treatment for flag \'featureC\': Invalid treatment (max 100 chars and must match pattern)' + ); + }); test('returns specific fallback if flag exists', () => { const config: FallbackTreatmentConfiguration = { @@ -9,7 +93,7 @@ describe('FallbackTreatmentsCalculator', () => { 'featureA': { treatment: 'TREATMENT_A', config: '{ value: 1 }' }, }, }; - const calculator = new FallbackTreatmentsCalculator(config); + const calculator = new FallbackTreatmentsCalculator(loggerMock, config); const result = calculator.resolve('featureA', 'label by flag'); expect(result).toEqual({ @@ -24,7 +108,7 @@ describe('FallbackTreatmentsCalculator', () => { byFlag: {}, global: { treatment: 'GLOBAL_TREATMENT', config: '{ global: true }' }, }; - const calculator = new FallbackTreatmentsCalculator(config); + const calculator = new FallbackTreatmentsCalculator(loggerMock, config); const result = calculator.resolve('missingFlag', 'label by global'); expect(result).toEqual({ @@ -38,29 +122,29 @@ describe('FallbackTreatmentsCalculator', () => { const config: FallbackTreatmentConfiguration = { byFlag: {}, }; - const calculator = new FallbackTreatmentsCalculator(config); + const calculator = new FallbackTreatmentsCalculator(loggerMock, config); const result = calculator.resolve('missingFlag', 'label by noFallback'); expect(result).toEqual({ - treatment: 'CONTROL', + treatment: CONTROL, config: null, - label: 'fallback - label by noFallback', + label: 'label by noFallback', }); }); test('returns undefined label if no label provided', () => { const config: FallbackTreatmentConfiguration = { byFlag: { - 'featureB': { treatment: 'TREATMENT_B' }, + 'featureB': { treatment: 'TREATMENT_B', config: '{ value: 1 }' }, }, }; - const calculator = new FallbackTreatmentsCalculator(config); + const calculator = new FallbackTreatmentsCalculator(loggerMock, config); const result = calculator.resolve('featureB'); expect(result).toEqual({ treatment: 'TREATMENT_B', - config: undefined, - label: undefined, + config: '{ value: 1 }', + label: '', }); }); }); diff --git a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts index 0f314e2a..aaaf106c 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-sanitizer.spec.ts @@ -1,5 +1,6 @@ import { FallbacksSanitizer } from '../fallbackSanitizer'; import { TreatmentWithConfig } from '../../../../types/splitio'; +import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; describe('FallbacksSanitizer', () => { const validTreatment: TreatmentWithConfig = { treatment: 'on', config: '{"color":"blue"}' }; @@ -10,7 +11,7 @@ describe('FallbacksSanitizer', () => { }); afterEach(() => { - (console.error as jest.Mock).mockRestore(); + (loggerMock.error as jest.Mock).mockRestore(); }); describe('isValidFlagName', () => { @@ -52,14 +53,14 @@ describe('FallbacksSanitizer', () => { describe('sanitizeGlobal', () => { test('returns the treatment if valid', () => { - expect(FallbacksSanitizer.sanitizeGlobal(validTreatment)).toEqual(validTreatment); - expect(console.error).not.toHaveBeenCalled(); + expect(FallbacksSanitizer.sanitizeGlobal(loggerMock, validTreatment)).toEqual(validTreatment); + expect(loggerMock.error).not.toHaveBeenCalled(); }); test('returns undefined and logs error if invalid', () => { - const result = FallbacksSanitizer.sanitizeGlobal(invalidTreatment); + const result = FallbacksSanitizer.sanitizeGlobal(loggerMock, invalidTreatment); expect(result).toBeUndefined(); - expect(console.error).toHaveBeenCalledWith( + expect(loggerMock.error).toHaveBeenCalledWith( expect.stringContaining('Fallback treatments - Discarded fallback') ); }); @@ -73,10 +74,10 @@ describe('FallbacksSanitizer', () => { bad_treatment: invalidTreatment, }; - const result = FallbacksSanitizer.sanitizeByFlag(input); + const result = FallbacksSanitizer.sanitizeByFlag(loggerMock, input); expect(result).toEqual({ valid_flag: validTreatment }); - expect(console.error).toHaveBeenCalledTimes(2); // invalid flag + bad_treatment + expect(loggerMock.error).toHaveBeenCalledTimes(2); // invalid flag + bad_treatment }); test('returns empty object if all invalid', () => { @@ -84,9 +85,9 @@ describe('FallbacksSanitizer', () => { 'invalid flag': invalidTreatment, }; - const result = FallbacksSanitizer.sanitizeByFlag(input); + const result = FallbacksSanitizer.sanitizeByFlag(loggerMock, input); expect(result).toEqual({}); - expect(console.error).toHaveBeenCalled(); + expect(loggerMock.error).toHaveBeenCalled(); }); test('returns same object if all valid', () => { @@ -95,9 +96,9 @@ describe('FallbacksSanitizer', () => { flag_two: { treatment: 'valid_2', config: null }, }; - const result = FallbacksSanitizer.sanitizeByFlag(input); + const result = FallbacksSanitizer.sanitizeByFlag(loggerMock, input); expect(result).toEqual(input); - expect(console.error).not.toHaveBeenCalled(); + expect(loggerMock.error).not.toHaveBeenCalled(); }); }); }); diff --git a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts index 06c7fe2e..d7996bbb 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts @@ -1,4 +1,6 @@ -import { FallbackTreatment } from '../../../../types/splitio'; +import { FallbackTreatment, Treatment, TreatmentWithConfig } from '../../../../types/splitio'; +import { ILogger } from '../../../logger/types'; +import { isObject, isString } from '../../../utils/lang'; import { FallbackDiscardReason } from '../constants'; @@ -10,51 +12,43 @@ export class FallbacksSanitizer { return name.length <= 100 && !name.includes(' '); } - private static isValidTreatment(t?: FallbackTreatment): boolean { - if (!t) { - return false; - } - - if (typeof t === 'string') { - if (t.length > 100) { - return false; - } - return FallbacksSanitizer.pattern.test(t); - } + private static isValidTreatment(t?: Treatment | FallbackTreatment): boolean { + const treatment = isObject(t) ? (t as TreatmentWithConfig).treatment : t; - const { treatment } = t; - if (!treatment || treatment.length > 100) { + if (!isString(treatment) || treatment.length > 100) { return false; } return FallbacksSanitizer.pattern.test(treatment); } - static sanitizeGlobal(treatment?: FallbackTreatment): FallbackTreatment | undefined { + static sanitizeGlobal(logger: ILogger, treatment?: string | FallbackTreatment): string | FallbackTreatment | undefined { if (!this.isValidTreatment(treatment)) { - console.error( + logger.error( `Fallback treatments - Discarded fallback: ${FallbackDiscardReason.Treatment}` ); return undefined; } - return treatment!; + return treatment; } static sanitizeByFlag( - byFlagFallbacks: Record - ): Record { - const sanitizedByFlag: Record = {}; - - const entries = Object.entries(byFlagFallbacks); - entries.forEach(([flag, t]) => { + logger: ILogger, + byFlagFallbacks: Record + ): Record { + const sanitizedByFlag: Record = {}; + + const entries = Object.keys(byFlagFallbacks); + entries.forEach((flag) => { + const t = byFlagFallbacks[flag]; if (!this.isValidFlagName(flag)) { - console.error( + logger.error( `Fallback treatments - Discarded flag '${flag}': ${FallbackDiscardReason.FlagName}` ); return; } if (!this.isValidTreatment(t)) { - console.error( + logger.error( `Fallback treatments - Discarded treatment for flag '${flag}': ${FallbackDiscardReason.Treatment}` ); return; diff --git a/src/evaluator/fallbackTreatmentsCalculator/index.ts b/src/evaluator/fallbackTreatmentsCalculator/index.ts index 30ef69e2..521b9419 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/index.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/index.ts @@ -1,16 +1,28 @@ -import { FallbackTreatmentConfiguration, FallbackTreatment, IFallbackTreatmentsCalculator} from '../../../types/splitio'; +import { FallbackTreatmentConfiguration, FallbackTreatment } from '../../../types/splitio'; +import { FallbacksSanitizer } from './fallbackSanitizer'; +import { CONTROL } from '../../utils/constants'; +import { isString } from '../../utils/lang'; +import { ILogger } from '../../logger/types'; + +export type IFallbackTreatmentsCalculator = { + resolve(flagName: string, label?: string): FallbackTreatment & { label?: string }; +} export class FallbackTreatmentsCalculator implements IFallbackTreatmentsCalculator { private readonly labelPrefix = 'fallback - '; - private readonly control = 'CONTROL'; private readonly fallbacks: FallbackTreatmentConfiguration; - constructor(fallbacks: FallbackTreatmentConfiguration) { - this.fallbacks = fallbacks; + constructor(logger: ILogger, fallbacks?: FallbackTreatmentConfiguration) { + const sanitizedGlobal = fallbacks?.global ? FallbacksSanitizer.sanitizeGlobal(logger, fallbacks.global) : undefined; + const sanitizedByFlag = fallbacks?.byFlag ? FallbacksSanitizer.sanitizeByFlag(logger, fallbacks.byFlag) : {}; + this.fallbacks = { + global: sanitizedGlobal, + byFlag: sanitizedByFlag + }; } - resolve(flagName: string, label?: string | undefined): FallbackTreatment { - const treatment = this.fallbacks.byFlag[flagName]; + resolve(flagName: string, label?: string): FallbackTreatment & { label?: string } { + const treatment = this.fallbacks.byFlag?.[flagName]; if (treatment) { return this.copyWithLabel(treatment, label); } @@ -20,14 +32,14 @@ export class FallbackTreatmentsCalculator implements IFallbackTreatmentsCalculat } return { - treatment: this.control, + treatment: CONTROL, config: null, - label: this.resolveLabel(label), + label, }; } - private copyWithLabel(fallback: FallbackTreatment, label: string | undefined): FallbackTreatment { - if (typeof fallback === 'string') { + private copyWithLabel(fallback: string | FallbackTreatment, label?: string): FallbackTreatment & { label: string } { + if (isString(fallback)) { return { treatment: fallback, config: null, @@ -42,8 +54,8 @@ export class FallbackTreatmentsCalculator implements IFallbackTreatmentsCalculat }; } - private resolveLabel(label?: string | undefined): string | undefined { - return label ? `${this.labelPrefix}${label}` : undefined; + private resolveLabel(label?: string): string { + return label ? `${this.labelPrefix}${label}` : ''; } } diff --git a/src/sdkClient/__tests__/clientInputValidation.spec.ts b/src/sdkClient/__tests__/clientInputValidation.spec.ts index f70845f7..4a80541d 100644 --- a/src/sdkClient/__tests__/clientInputValidation.spec.ts +++ b/src/sdkClient/__tests__/clientInputValidation.spec.ts @@ -4,6 +4,7 @@ import { clientInputValidationDecorator } from '../clientInputValidation'; // Mocks import { DebugLogger } from '../../logger/browser/DebugLogger'; import { createClientMock } from './testUtils'; +import { FallbackTreatmentsCalculator, IFallbackTreatmentsCalculator } from '../../evaluator/fallbackTreatmentsCalculator'; const settings: any = { log: DebugLogger(), @@ -13,13 +14,15 @@ const settings: any = { const EVALUATION_RESULT = 'on'; const client: any = createClientMock(EVALUATION_RESULT); +const fallbackTreatmentsCalculator: IFallbackTreatmentsCalculator = new FallbackTreatmentsCalculator(settings); + const readinessManager: any = { isReady: () => true, isDestroyed: () => false }; describe('clientInputValidationDecorator', () => { - const clientWithValidation = clientInputValidationDecorator(settings, client, readinessManager); + const clientWithValidation = clientInputValidationDecorator(settings, client, readinessManager, fallbackTreatmentsCalculator); const logSpy = jest.spyOn(console, 'log'); beforeEach(() => { diff --git a/src/sdkClient/__tests__/sdkClientMethod.spec.ts b/src/sdkClient/__tests__/sdkClientMethod.spec.ts index e2f53f83..31b62356 100644 --- a/src/sdkClient/__tests__/sdkClientMethod.spec.ts +++ b/src/sdkClient/__tests__/sdkClientMethod.spec.ts @@ -4,6 +4,7 @@ import { sdkClientMethodFactory } from '../sdkClientMethod'; import { assertClientApi } from './testUtils'; import { telemetryTrackerFactory } from '../../trackers/telemetryTracker'; import { IBasicClient } from '../../types'; +import { FallbackTreatmentsCalculator } from '../../evaluator/fallbackTreatmentsCalculator'; const errorMessage = 'Shared Client not supported by the storage mechanism. Create isolated instances instead.'; @@ -17,7 +18,8 @@ const paramMocks = [ settings: { mode: CONSUMER_MODE, log: loggerMock, core: { authorizationKey: 'sdk key '} }, telemetryTracker: telemetryTrackerFactory(), clients: {}, - uniqueKeysTracker: { start: jest.fn(), stop: jest.fn() } + uniqueKeysTracker: { start: jest.fn(), stop: jest.fn() }, + fallbackTreatmentsCalculator: new FallbackTreatmentsCalculator(loggerMock, {}) }, // SyncManager (i.e., Sync SDK) and Signal listener { @@ -28,7 +30,8 @@ const paramMocks = [ settings: { mode: STANDALONE_MODE, log: loggerMock, core: { authorizationKey: 'sdk key '} }, telemetryTracker: telemetryTrackerFactory(), clients: {}, - uniqueKeysTracker: { start: jest.fn(), stop: jest.fn() } + uniqueKeysTracker: { start: jest.fn(), stop: jest.fn() }, + fallbackTreatmentsCalculator: new FallbackTreatmentsCalculator(loggerMock, {}) } ]; diff --git a/src/sdkClient/client.ts b/src/sdkClient/client.ts index 0e526f72..2fbb7dd7 100644 --- a/src/sdkClient/client.ts +++ b/src/sdkClient/client.ts @@ -35,7 +35,7 @@ function stringify(options?: SplitIO.EvaluationOptions) { * Creator of base client with getTreatments and track methods. */ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | SplitIO.IAsyncClient { - const { sdkReadinessManager: { readinessManager }, storage, settings, impressionsTracker, eventTracker, telemetryTracker } = params; + const { sdkReadinessManager: { readinessManager }, storage, settings, impressionsTracker, eventTracker, telemetryTracker, fallbackTreatmentsCalculator } = params; const { log, mode } = settings; const isAsync = isConsumerMode(mode); @@ -143,9 +143,17 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl const matchingKey = getMatching(key); const bucketingKey = getBucketing(key); - const { treatment, label, changeNumber, config = null, impressionsDisabled } = evaluation; + const { changeNumber, impressionsDisabled } = evaluation; + let { treatment, label, config = null } = evaluation; log.info(IMPRESSION, [featureFlagName, matchingKey, treatment, label]); + if (treatment === CONTROL) { + const fallbackTreatment = fallbackTreatmentsCalculator.resolve(featureFlagName, label); + treatment = fallbackTreatment.treatment; + label = fallbackTreatment.label ? fallbackTreatment.label : label; + config = fallbackTreatment.config; + } + if (validateSplitExistence(log, readinessManager, featureFlagName, label, invokingMethodName)) { log.info(IMPRESSION_QUEUEING); queue.push({ diff --git a/src/sdkClient/clientInputValidation.ts b/src/sdkClient/clientInputValidation.ts index 40765d41..bd371a7c 100644 --- a/src/sdkClient/clientInputValidation.ts +++ b/src/sdkClient/clientInputValidation.ts @@ -1,4 +1,3 @@ -import { objectAssign } from '../utils/lang/objectAssign'; import { validateAttributes, validateEvent, @@ -13,19 +12,20 @@ import { validateEvaluationOptions } from '../utils/inputValidation'; import { startsWith } from '../utils/lang'; -import { CONTROL, CONTROL_WITH_CONFIG, GET_TREATMENT, GET_TREATMENTS, GET_TREATMENTS_BY_FLAG_SET, GET_TREATMENTS_BY_FLAG_SETS, GET_TREATMENTS_WITH_CONFIG, GET_TREATMENTS_WITH_CONFIG_BY_FLAG_SET, GET_TREATMENTS_WITH_CONFIG_BY_FLAG_SETS, GET_TREATMENT_WITH_CONFIG, TRACK_FN_LABEL } from '../utils/constants'; +import { GET_TREATMENT, GET_TREATMENTS, GET_TREATMENTS_BY_FLAG_SET, GET_TREATMENTS_BY_FLAG_SETS, GET_TREATMENTS_WITH_CONFIG, GET_TREATMENTS_WITH_CONFIG_BY_FLAG_SET, GET_TREATMENTS_WITH_CONFIG_BY_FLAG_SETS, GET_TREATMENT_WITH_CONFIG, TRACK_FN_LABEL } from '../utils/constants'; import { IReadinessManager } from '../readiness/types'; import { MaybeThenable } from '../dtos/types'; import { ISettings } from '../types'; import SplitIO from '../../types/splitio'; import { isConsumerMode } from '../utils/settingsValidation/mode'; import { validateFlagSets } from '../utils/settingsValidation/splitFilters'; +import { IFallbackTreatmentsCalculator } from '../evaluator/fallbackTreatmentsCalculator'; /** * Decorator that validates the input before actually executing the client methods. * We should "guard" the client here, while not polluting the "real" implementation of those methods. */ -export function clientInputValidationDecorator(settings: ISettings, client: TClient, readinessManager: IReadinessManager): TClient { +export function clientInputValidationDecorator(settings: ISettings, client: TClient, readinessManager: IReadinessManager, fallbackTreatmentsCalculator: IFallbackTreatmentsCalculator): TClient { const { log, mode } = settings; const isAsync = isConsumerMode(mode); @@ -59,6 +59,19 @@ export function clientInputValidationDecorator(value: T): MaybeThenable { return isAsync ? Promise.resolve(value) : value; } @@ -69,7 +82,8 @@ export function clientInputValidationDecorator res[split] = CONTROL); + if (params.nameOrNames) (params.nameOrNames as string[]).forEach((split: string) => res[split] = evaluateFallBackTreatment(split, false) as SplitIO.Treatment); return wrapResult(res); } @@ -103,7 +118,7 @@ export function clientInputValidationDecorator res[split] = objectAssign({}, CONTROL_WITH_CONFIG)); + if (params.nameOrNames) (params.nameOrNames as string[]).forEach(split => res[split] = evaluateFallBackTreatment(split, true) as SplitIO.TreatmentWithConfig); return wrapResult(res); } diff --git a/src/sdkClient/sdkClient.ts b/src/sdkClient/sdkClient.ts index cc44c9f7..01fee12b 100644 --- a/src/sdkClient/sdkClient.ts +++ b/src/sdkClient/sdkClient.ts @@ -43,7 +43,8 @@ export function sdkClientFactory(params: ISdkFactoryContext, isSharedClient?: bo clientInputValidationDecorator( settings, clientFactory(params), - sdkReadinessManager.readinessManager + sdkReadinessManager.readinessManager, + params.fallbackTreatmentsCalculator ), // Sdk destroy diff --git a/src/sdkFactory/__tests__/index.spec.ts b/src/sdkFactory/__tests__/index.spec.ts index e46296be..2165bcd4 100644 --- a/src/sdkFactory/__tests__/index.spec.ts +++ b/src/sdkFactory/__tests__/index.spec.ts @@ -3,6 +3,7 @@ import { sdkFactory } from '../index'; import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks'; import SplitIO from '../../../types/splitio'; import { EventEmitter } from '../../utils/MinEvents'; +import { FallbackTreatmentsCalculator } from '../../evaluator/fallbackTreatmentsCalculator'; /** Mocks */ @@ -36,6 +37,7 @@ const paramsForAsyncSDK = { platform: { EventEmitter }, + fallbackTreatmentsCalculator: new FallbackTreatmentsCalculator(fullSettings.log) }; const SignalListenerInstanceMock = { start: jest.fn() }; diff --git a/src/sdkFactory/index.ts b/src/sdkFactory/index.ts index eba01028..5e38d5a7 100644 --- a/src/sdkFactory/index.ts +++ b/src/sdkFactory/index.ts @@ -17,6 +17,7 @@ import { DEBUG, OPTIMIZED } from '../utils/constants'; import { setRolloutPlan } from '../storages/setRolloutPlan'; import { IStorageSync } from '../storages/types'; import { getMatching } from '../utils/key'; +import { FallbackTreatmentsCalculator } from '../evaluator/fallbackTreatmentsCalculator'; /** * Modular SDK factory @@ -60,6 +61,8 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA } }); + const fallbackTreatmentsCalculator = new FallbackTreatmentsCalculator(settings.log, settings.fallbackTreatments); + if (initialRolloutPlan) { setRolloutPlan(log, initialRolloutPlan, storage as IStorageSync, key && getMatching(key)); if ((storage as IStorageSync).splits.getChangeNumber() > -1) readiness.splits.emit(SDK_SPLITS_CACHE_LOADED); @@ -85,7 +88,7 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA // splitApi is used by SyncManager and Browser signal listener const splitApi = splitApiFactory && splitApiFactory(settings, platform, telemetryTracker); - const ctx: ISdkFactoryContext = { clients, splitApi, eventTracker, impressionsTracker, telemetryTracker, uniqueKeysTracker, sdkReadinessManager, readiness, settings, storage, platform }; + const ctx: ISdkFactoryContext = { clients, splitApi, eventTracker, impressionsTracker, telemetryTracker, uniqueKeysTracker, sdkReadinessManager, readiness, settings, storage, platform, fallbackTreatmentsCalculator }; const syncManager = syncManagerFactory && syncManagerFactory(ctx as ISdkFactoryContextSync); ctx.syncManager = syncManager; diff --git a/src/sdkFactory/types.ts b/src/sdkFactory/types.ts index 25882c38..00890c8e 100644 --- a/src/sdkFactory/types.ts +++ b/src/sdkFactory/types.ts @@ -3,6 +3,7 @@ import { ISignalListener } from '../listeners/types'; import { IReadinessManager, ISdkReadinessManager } from '../readiness/types'; import type { sdkManagerFactory } from '../sdkManager'; import type { splitApiFactory } from '../services/splitApi'; +import type { IFallbackTreatmentsCalculator } from '../evaluator/fallbackTreatmentsCalculator'; import { IFetch, ISplitApi, IEventSourceConstructor } from '../services/types'; import { IStorageAsync, IStorageSync, IStorageFactoryParams } from '../storages/types'; import { ISyncManager } from '../sync/types'; @@ -51,6 +52,7 @@ export interface ISdkFactoryContext { splitApi?: ISplitApi syncManager?: ISyncManager, clients: Record, + fallbackTreatmentsCalculator: IFallbackTreatmentsCalculator } export interface ISdkFactoryContextSync extends ISdkFactoryContext { diff --git a/types/splitio.d.ts b/types/splitio.d.ts index f1ac014f..cfe069ad 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -1233,25 +1233,18 @@ declare namespace SplitIO { */ type ConsentStatus = 'GRANTED' | 'DECLINED' | 'UNKNOWN'; /** - * Fallback treatment can be either a string (treatment) or an object with treatment, config and label. + * Fallback treatment can be either a string (Treatment) or an object with treatment and config (TreatmentWithConfig). */ - type FallbackTreatment = string | { - treatment: string; - config?: string | null; - label?: string | null; - }; + type FallbackTreatment = TreatmentWithConfig; /** * Fallback treatments to be used when the SDK is not ready or the flag is not found. */ type FallbackTreatmentConfiguration = { - global?: FallbackTreatment, - byFlag: { - [key: string]: FallbackTreatment + global?: string | FallbackTreatment, + byFlag?: { + [key: string]: string | FallbackTreatment } } - type IFallbackTreatmentsCalculator = { - resolve(flagName: string, label?: string | undefined): FallbackTreatment; - } /** * Logger. Its interface details are not part of the public API. It shouldn't be used directly. */ From d9c4cff5923626d923bec79604f0cb2985d66642 Mon Sep 17 00:00:00 2001 From: ZamoraEmmanuel <87494075+ZamoraEmmanuel@users.noreply.github.com> Date: Thu, 23 Oct 2025 14:09:57 -0300 Subject: [PATCH 4/9] Update src/sdkClient/clientInputValidation.ts Co-authored-by: Emiliano Sanchez --- src/sdkClient/clientInputValidation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sdkClient/clientInputValidation.ts b/src/sdkClient/clientInputValidation.ts index bd371a7c..443c31f4 100644 --- a/src/sdkClient/clientInputValidation.ts +++ b/src/sdkClient/clientInputValidation.ts @@ -60,7 +60,7 @@ export function clientInputValidationDecorator Date: Fri, 24 Oct 2025 13:42:42 -0300 Subject: [PATCH 5/9] review changes and add fallbacklabel to avoid impression --- .../__tests__/fallback-calculator.spec.ts | 16 --------------- .../fallbackSanitizer/index.ts | 12 +++++------ .../fallbackTreatmentsCalculator/index.ts | 20 ++++++++----------- src/sdkClient/client.ts | 3 ++- src/utils/inputValidation/splitExistence.ts | 4 ++-- src/utils/labels/index.ts | 3 +++ types/splitio.d.ts | 8 ++------ 7 files changed, 23 insertions(+), 43 deletions(-) diff --git a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts index 4a1e9d93..6240e2a0 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/__tests__/fallback-calculator.spec.ts @@ -131,20 +131,4 @@ describe('FallbackTreatmentsCalculator' , () => { label: 'label by noFallback', }); }); - - test('returns undefined label if no label provided', () => { - const config: FallbackTreatmentConfiguration = { - byFlag: { - 'featureB': { treatment: 'TREATMENT_B', config: '{ value: 1 }' }, - }, - }; - const calculator = new FallbackTreatmentsCalculator(loggerMock, config); - const result = calculator.resolve('featureB'); - - expect(result).toEqual({ - treatment: 'TREATMENT_B', - config: '{ value: 1 }', - label: '', - }); - }); }); diff --git a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts index d7996bbb..66ec65f3 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/fallbackSanitizer/index.ts @@ -1,4 +1,4 @@ -import { FallbackTreatment, Treatment, TreatmentWithConfig } from '../../../../types/splitio'; +import { Treatment, TreatmentWithConfig } from '../../../../types/splitio'; import { ILogger } from '../../../logger/types'; import { isObject, isString } from '../../../utils/lang'; import { FallbackDiscardReason } from '../constants'; @@ -12,7 +12,7 @@ export class FallbacksSanitizer { return name.length <= 100 && !name.includes(' '); } - private static isValidTreatment(t?: Treatment | FallbackTreatment): boolean { + private static isValidTreatment(t?: Treatment | TreatmentWithConfig): boolean { const treatment = isObject(t) ? (t as TreatmentWithConfig).treatment : t; if (!isString(treatment) || treatment.length > 100) { @@ -21,7 +21,7 @@ export class FallbacksSanitizer { return FallbacksSanitizer.pattern.test(treatment); } - static sanitizeGlobal(logger: ILogger, treatment?: string | FallbackTreatment): string | FallbackTreatment | undefined { + static sanitizeGlobal(logger: ILogger, treatment?: Treatment | TreatmentWithConfig): Treatment | TreatmentWithConfig | undefined { if (!this.isValidTreatment(treatment)) { logger.error( `Fallback treatments - Discarded fallback: ${FallbackDiscardReason.Treatment}` @@ -33,9 +33,9 @@ export class FallbacksSanitizer { static sanitizeByFlag( logger: ILogger, - byFlagFallbacks: Record - ): Record { - const sanitizedByFlag: Record = {}; + byFlagFallbacks: Record + ): Record { + const sanitizedByFlag: Record = {}; const entries = Object.keys(byFlagFallbacks); entries.forEach((flag) => { diff --git a/src/evaluator/fallbackTreatmentsCalculator/index.ts b/src/evaluator/fallbackTreatmentsCalculator/index.ts index 521b9419..7921f72c 100644 --- a/src/evaluator/fallbackTreatmentsCalculator/index.ts +++ b/src/evaluator/fallbackTreatmentsCalculator/index.ts @@ -1,15 +1,16 @@ -import { FallbackTreatmentConfiguration, FallbackTreatment } from '../../../types/splitio'; +import { FallbackTreatmentConfiguration, Treatment, TreatmentWithConfig } from '../../../types/splitio'; import { FallbacksSanitizer } from './fallbackSanitizer'; import { CONTROL } from '../../utils/constants'; import { isString } from '../../utils/lang'; import { ILogger } from '../../logger/types'; export type IFallbackTreatmentsCalculator = { - resolve(flagName: string, label?: string): FallbackTreatment & { label?: string }; + resolve(flagName: string, label: string): TreatmentWithConfig & { label: string }; } +export const FALLBACK_PREFIX = 'fallback - '; + export class FallbackTreatmentsCalculator implements IFallbackTreatmentsCalculator { - private readonly labelPrefix = 'fallback - '; private readonly fallbacks: FallbackTreatmentConfiguration; constructor(logger: ILogger, fallbacks?: FallbackTreatmentConfiguration) { @@ -21,7 +22,7 @@ export class FallbackTreatmentsCalculator implements IFallbackTreatmentsCalculat }; } - resolve(flagName: string, label?: string): FallbackTreatment & { label?: string } { + resolve(flagName: string, label: string): TreatmentWithConfig & { label: string } { const treatment = this.fallbacks.byFlag?.[flagName]; if (treatment) { return this.copyWithLabel(treatment, label); @@ -38,24 +39,19 @@ export class FallbackTreatmentsCalculator implements IFallbackTreatmentsCalculat }; } - private copyWithLabel(fallback: string | FallbackTreatment, label?: string): FallbackTreatment & { label: string } { + private copyWithLabel(fallback: Treatment | TreatmentWithConfig, label: string): TreatmentWithConfig & { label: string } { if (isString(fallback)) { return { treatment: fallback, config: null, - label: this.resolveLabel(label), + label: `${FALLBACK_PREFIX}${label}`, }; } return { treatment: fallback.treatment, config: fallback.config, - label: this.resolveLabel(label), + label: `${FALLBACK_PREFIX}${label}`, }; } - - private resolveLabel(label?: string): string { - return label ? `${this.labelPrefix}${label}` : ''; - } - } diff --git a/src/sdkClient/client.ts b/src/sdkClient/client.ts index 2fbb7dd7..255ed918 100644 --- a/src/sdkClient/client.ts +++ b/src/sdkClient/client.ts @@ -145,7 +145,6 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl const { changeNumber, impressionsDisabled } = evaluation; let { treatment, label, config = null } = evaluation; - log.info(IMPRESSION, [featureFlagName, matchingKey, treatment, label]); if (treatment === CONTROL) { const fallbackTreatment = fallbackTreatmentsCalculator.resolve(featureFlagName, label); @@ -154,6 +153,8 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl config = fallbackTreatment.config; } + log.info(IMPRESSION, [featureFlagName, matchingKey, treatment, label]); + if (validateSplitExistence(log, readinessManager, featureFlagName, label, invokingMethodName)) { log.info(IMPRESSION_QUEUEING); queue.push({ diff --git a/src/utils/inputValidation/splitExistence.ts b/src/utils/inputValidation/splitExistence.ts index 2f3105f9..f98d81b7 100644 --- a/src/utils/inputValidation/splitExistence.ts +++ b/src/utils/inputValidation/splitExistence.ts @@ -1,4 +1,4 @@ -import { SPLIT_NOT_FOUND } from '../labels'; +import { FALLBACK_SPLIT_NOT_FOUND, SPLIT_NOT_FOUND } from '../labels'; import { IReadinessManager } from '../../readiness/types'; import { ILogger } from '../../logger/types'; import { WARN_NOT_EXISTENT_SPLIT } from '../../logger/constants'; @@ -9,7 +9,7 @@ import { WARN_NOT_EXISTENT_SPLIT } from '../../logger/constants'; */ export function validateSplitExistence(log: ILogger, readinessManager: IReadinessManager, splitName: string, labelOrSplitObj: any, method: string): boolean { if (readinessManager.isReady()) { // Only if it's ready we validate this, otherwise it may just be that the SDK is not ready yet. - if (labelOrSplitObj === SPLIT_NOT_FOUND || labelOrSplitObj == null) { + if (labelOrSplitObj === SPLIT_NOT_FOUND || labelOrSplitObj == null || labelOrSplitObj === FALLBACK_SPLIT_NOT_FOUND) { log.warn(WARN_NOT_EXISTENT_SPLIT, [method, splitName]); return false; } diff --git a/src/utils/labels/index.ts b/src/utils/labels/index.ts index 957100d7..78117a1d 100644 --- a/src/utils/labels/index.ts +++ b/src/utils/labels/index.ts @@ -1,3 +1,5 @@ +import { FALLBACK_PREFIX } from '../../evaluator/fallbackTreatmentsCalculator'; + export const SPLIT_KILLED = 'killed'; export const NO_CONDITION_MATCH = 'default rule'; export const SPLIT_NOT_FOUND = 'definition not found'; @@ -7,3 +9,4 @@ export const SPLIT_ARCHIVED = 'archived'; export const NOT_IN_SPLIT = 'not in split'; export const UNSUPPORTED_MATCHER_TYPE = 'targeting rule type unsupported by sdk'; export const PREREQUISITES_NOT_MET = 'prerequisites not met'; +export const FALLBACK_SPLIT_NOT_FOUND = FALLBACK_PREFIX + SPLIT_NOT_FOUND; diff --git a/types/splitio.d.ts b/types/splitio.d.ts index cfe069ad..23bd3226 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -1232,17 +1232,13 @@ declare namespace SplitIO { * User consent status. */ type ConsentStatus = 'GRANTED' | 'DECLINED' | 'UNKNOWN'; - /** - * Fallback treatment can be either a string (Treatment) or an object with treatment and config (TreatmentWithConfig). - */ - type FallbackTreatment = TreatmentWithConfig; /** * Fallback treatments to be used when the SDK is not ready or the flag is not found. */ type FallbackTreatmentConfiguration = { - global?: string | FallbackTreatment, + global?: Treatment | TreatmentWithConfig, byFlag?: { - [key: string]: string | FallbackTreatment + [featureFlagName: string]: Treatment | TreatmentWithConfig } } /** From eeb8073521e830563a447830259977707933164d Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Fri, 24 Oct 2025 13:58:03 -0300 Subject: [PATCH 6/9] Prepare release v2.8.0 --- CHANGES.txt | 3 +++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 711d3ca3..f302f601 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,6 @@ +2.8.0 (October 24, 2025) + - Added new configuration for Fallback Treatments, which allows setting a treatment value and optional config to be returned in place of "control", either globally or by flag. Read more in our docs. + 2.7.0 (October 7, 2025) - Added support for custom loggers: added `logger` configuration option and `LoggerAPI.setLogger` method to allow the SDK to use a custom logger. diff --git a/package-lock.json b/package-lock.json index 7665ca86..a6093d8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.7.0", + "version": "2.8.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-commons", - "version": "2.7.0", + "version": "2.8.0", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", diff --git a/package.json b/package.json index c14387e2..ab0a1d2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.7.0", + "version": "2.8.0", "description": "Split JavaScript SDK common components", "main": "cjs/index.js", "module": "esm/index.js", From 6cf13c9ef8d5cb3382bb2ffef09818d6d2f6d5ce Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Fri, 24 Oct 2025 16:00:27 -0300 Subject: [PATCH 7/9] remove unnecessary validation --- src/sdkClient/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sdkClient/client.ts b/src/sdkClient/client.ts index 255ed918..e8451c34 100644 --- a/src/sdkClient/client.ts +++ b/src/sdkClient/client.ts @@ -149,7 +149,7 @@ export function clientFactory(params: ISdkFactoryContext): SplitIO.IClient | Spl if (treatment === CONTROL) { const fallbackTreatment = fallbackTreatmentsCalculator.resolve(featureFlagName, label); treatment = fallbackTreatment.treatment; - label = fallbackTreatment.label ? fallbackTreatment.label : label; + label = fallbackTreatment.label; config = fallbackTreatment.config; } From ad8d66ac38d9d3caed6a5866e15f1660090a0038 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Mon, 27 Oct 2025 18:59:50 -0300 Subject: [PATCH 8/9] rc --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a6093d8d..7284cf89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.8.0", + "version": "2.7.9-rc.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-commons", - "version": "2.8.0", + "version": "2.7.9-rc.1", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", diff --git a/package.json b/package.json index ab0a1d2f..ed693bcd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.8.0", + "version": "2.7.9-rc.1", "description": "Split JavaScript SDK common components", "main": "cjs/index.js", "module": "esm/index.js", From f4145a969abd49f1ea3388278dba03cf2f353dfe Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 28 Oct 2025 15:02:11 -0300 Subject: [PATCH 9/9] stable version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7284cf89..a6093d8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.7.9-rc.1", + "version": "2.8.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-commons", - "version": "2.7.9-rc.1", + "version": "2.8.0", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", diff --git a/package.json b/package.json index ed693bcd..ab0a1d2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.7.9-rc.1", + "version": "2.8.0", "description": "Split JavaScript SDK common components", "main": "cjs/index.js", "module": "esm/index.js",