diff --git a/package.json b/package.json index 2f6f66f..26acec6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eppo/js-client-sdk-common", - "version": "4.9.0", + "version": "4.10.0", "description": "Common library for Eppo JavaScript SDKs (web, react native, and node)", "main": "dist/index.js", "files": [ diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index 707d567..84d2212 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -14,6 +14,7 @@ import { validateTestAssignments, } from '../../test/testHelpers'; import { IAssignmentLogger } from '../assignment-logger'; +import { AssignmentCache } from '../cache/abstract-assignment-cache'; import { IConfigurationWire, IObfuscatedPrecomputedConfigurationResponse, @@ -23,7 +24,7 @@ import { IConfigurationStore } from '../configuration-store/configuration-store' import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { MAX_EVENT_QUEUE_SIZE, DEFAULT_POLL_INTERVAL_MS, POLL_JITTER_PCT } from '../constants'; import { decodePrecomputedFlag } from '../decoding'; -import { Flag, ObfuscatedFlag, VariationType } from '../interfaces'; +import { Flag, ObfuscatedFlag, Variation, VariationType } from '../interfaces'; import { getMD5Hash } from '../obfuscation'; import { AttributeType } from '../types'; @@ -945,4 +946,225 @@ describe('EppoClient E2E test', () => { ); }); }); + + describe('flag overrides', () => { + let client: EppoClient; + let mockLogger: IAssignmentLogger; + let overrideStore: IConfigurationStore; + + beforeEach(() => { + storage.setEntries({ [flagKey]: mockFlag }); + mockLogger = td.object(); + overrideStore = new MemoryOnlyConfigurationStore(); + client = new EppoClient({ + flagConfigurationStore: storage, + overrideStore: overrideStore, + }); + client.setAssignmentLogger(mockLogger); + client.useNonExpiringInMemoryAssignmentCache(); + }); + + it('returns override values for all supported types', () => { + overrideStore.setEntries({ + 'string-flag': { + key: 'override-variation', + value: 'override-string', + }, + 'boolean-flag': { + key: 'override-variation', + value: true, + }, + 'numeric-flag': { + key: 'override-variation', + value: 42.5, + }, + 'json-flag': { + key: 'override-variation', + value: '{"foo": "bar"}', + }, + }); + + expect(client.getStringAssignment('string-flag', 'subject-10', {}, 'default')).toBe( + 'override-string', + ); + expect(client.getBooleanAssignment('boolean-flag', 'subject-10', {}, false)).toBe(true); + expect(client.getNumericAssignment('numeric-flag', 'subject-10', {}, 0)).toBe(42.5); + expect(client.getJSONAssignment('json-flag', 'subject-10', {}, {})).toEqual({ foo: 'bar' }); + }); + + it('does not log assignments when override is applied', () => { + overrideStore.setEntries({ + [flagKey]: { + key: 'override-variation', + value: 'override-value', + }, + }); + + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); + + expect(td.explain(mockLogger.logAssignment).callCount).toBe(0); + }); + + it('includes override details in assignment details', () => { + overrideStore.setEntries({ + [flagKey]: { + key: 'override-variation', + value: 'override-value', + }, + }); + + const result = client.getStringAssignmentDetails( + flagKey, + 'subject-10', + { foo: 3 }, + 'default', + ); + + expect(result).toMatchObject({ + variation: 'override-value', + evaluationDetails: { + flagEvaluationCode: 'MATCH', + flagEvaluationDescription: 'Flag override applied', + }, + }); + }); + + it('does not update assignment cache when override is applied', () => { + const mockAssignmentCache = td.object(); + td.when(mockAssignmentCache.has(td.matchers.anything())).thenReturn(false); + td.when(mockAssignmentCache.set(td.matchers.anything())).thenReturn(); + client.useCustomAssignmentCache(mockAssignmentCache); + + overrideStore.setEntries({ + [flagKey]: { + key: 'override-variation', + value: 'override-value', + }, + }); + + // First call with override + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); + + // Verify cache was not used at all + expect(td.explain(mockAssignmentCache.set).callCount).toBe(0); + + // Remove override + overrideStore.setEntries({}); + + // Second call without override + client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); + + // Now cache should be used + expect(td.explain(mockAssignmentCache.set).callCount).toBe(1); + }); + + it('uses normal assignment when no override exists for flag', () => { + // Set override for a different flag + overrideStore.setEntries({ + 'other-flag': { + key: 'override-variation', + value: 'override-value', + }, + }); + + const result = client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); + + // Should get the normal assignment value from mockFlag + expect(result).toBe(variationA.value); + expect(td.explain(mockLogger.logAssignment).callCount).toBe(1); + }); + + it('uses normal assignment when no overrides store is configured', () => { + // Create client without overrides store + const clientWithoutOverrides = new EppoClient({ + flagConfigurationStore: storage, + }); + clientWithoutOverrides.setAssignmentLogger(mockLogger); + + const result = clientWithoutOverrides.getStringAssignment( + flagKey, + 'subject-10', + {}, + 'default', + ); + + // Should get the normal assignment value from mockFlag + expect(result).toBe(variationA.value); + expect(td.explain(mockLogger.logAssignment).callCount).toBe(1); + }); + + it('respects override after initial assignment without override', () => { + // First call without override + const initialAssignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); + expect(initialAssignment).toBe(variationA.value); + expect(td.explain(mockLogger.logAssignment).callCount).toBe(1); + + // Set override and make second call + overrideStore.setEntries({ + [flagKey]: { + key: 'override-variation', + value: 'override-value', + }, + }); + + const overriddenAssignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); + expect(overriddenAssignment).toBe('override-value'); + // No additional logging should occur when using override + expect(td.explain(mockLogger.logAssignment).callCount).toBe(1); + }); + + it('reverts to normal assignment after removing override', () => { + // Set initial override + overrideStore.setEntries({ + [flagKey]: { + key: 'override-variation', + value: 'override-value', + }, + }); + + const overriddenAssignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); + expect(overriddenAssignment).toBe('override-value'); + expect(td.explain(mockLogger.logAssignment).callCount).toBe(0); + + // Remove override and make second call + overrideStore.setEntries({}); + + const normalAssignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); + expect(normalAssignment).toBe(variationA.value); + // Should log the normal assignment + expect(td.explain(mockLogger.logAssignment).callCount).toBe(1); + }); + + it('reverts to normal assignment after unsetting overrides store', () => { + overrideStore.setEntries({ + [flagKey]: { + key: 'override-variation', + value: 'override-value', + }, + }); + + client.unsetOverrideStore(); + + const normalAssignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default'); + expect(normalAssignment).toBe(variationA.value); + }); + + it('returns a mapping of flag key to variation key for all active overrides', () => { + overrideStore.setEntries({ + [flagKey]: { + key: 'override-variation', + value: 'override-value', + }, + 'other-flag': { + key: 'other-variation', + value: 'other-value', + }, + }); + + expect(client.getOverrideVariationKeys()).toEqual({ + [flagKey]: 'override-variation', + 'other-flag': 'other-variation', + }); + }); + }); }); diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 20e8861..c0dcde9 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -21,7 +21,7 @@ import { PrecomputedConfiguration, } from '../configuration'; import ConfigurationRequestor from '../configuration-requestor'; -import { IConfigurationStore } from '../configuration-store/configuration-store'; +import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store'; import { DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES, DEFAULT_POLL_CONFIG_REQUEST_RETRIES, @@ -30,7 +30,7 @@ import { } from '../constants'; import { decodeFlag } from '../decoding'; import { EppoValue } from '../eppo_value'; -import { Evaluator, FlagEvaluation, noneResult } from '../evaluator'; +import { Evaluator, FlagEvaluation, noneResult, overrideResult } from '../evaluator'; import { BoundedEventQueue } from '../events/bounded-event-queue'; import EventDispatcher from '../events/event-dispatcher'; import NoOpEventDispatcher from '../events/no-op-event-dispatcher'; @@ -116,6 +116,7 @@ export default class EppoClient { private configurationRequestParameters?: FlagConfigurationRequestParameters; private banditModelConfigurationStore?: IConfigurationStore; private banditVariationConfigurationStore?: IConfigurationStore; + private overrideStore?: ISyncStore; private flagConfigurationStore: IConfigurationStore; private assignmentLogger?: IAssignmentLogger; private assignmentCache?: AssignmentCache; @@ -131,12 +132,24 @@ export default class EppoClient { flagConfigurationStore, banditVariationConfigurationStore, banditModelConfigurationStore, + overrideStore, configurationRequestParameters, - }: EppoClientParameters) { + }: { + // Dispatcher for arbitrary, application-level events (not to be confused with Eppo specific assignment + // or bandit events). These events are application-specific and captures by EppoClient#track API. + eventDispatcher?: EventDispatcher; + flagConfigurationStore: IConfigurationStore; + banditVariationConfigurationStore?: IConfigurationStore; + banditModelConfigurationStore?: IConfigurationStore; + overrideStore?: ISyncStore; + configurationRequestParameters?: FlagConfigurationRequestParameters; + isObfuscated?: boolean; + }) { this.eventDispatcher = eventDispatcher; this.flagConfigurationStore = flagConfigurationStore; this.banditVariationConfigurationStore = banditVariationConfigurationStore; this.banditModelConfigurationStore = banditModelConfigurationStore; + this.overrideStore = overrideStore; this.configurationRequestParameters = configurationRequestParameters; this.isObfuscated = isObfuscated; } @@ -192,6 +205,24 @@ export default class EppoClient { this.isObfuscated = isObfuscated; } + setOverrideStore(store: ISyncStore): void { + this.overrideStore = store; + } + + unsetOverrideStore(): void { + this.overrideStore = undefined; + } + + // Returns a mapping of flag key to variation key for all active overrides + getOverrideVariationKeys(): Record { + return Object.fromEntries( + Object.entries(this.overrideStore?.entries() ?? {}).map(([flagKey, value]) => [ + flagKey, + value.key, + ]), + ); + } + async fetchFlagConfigurations() { if (!this.configurationRequestParameters) { throw new Error( @@ -940,6 +971,17 @@ export default class EppoClient { validateNotBlank(flagKey, 'Invalid argument: flagKey cannot be blank'); const flagEvaluationDetailsBuilder = this.newFlagEvaluationDetailsBuilder(flagKey); + const overrideVariation = this.overrideStore?.get(flagKey); + if (overrideVariation) { + return overrideResult( + flagKey, + subjectKey, + subjectAttributes, + overrideVariation, + flagEvaluationDetailsBuilder, + ); + } + const configDetails = this.getConfigDetails(); const flag = this.getFlag(flagKey); diff --git a/src/client/eppo-precomputed-client.spec.ts b/src/client/eppo-precomputed-client.spec.ts index 02f265d..79a5be3 100644 --- a/src/client/eppo-precomputed-client.spec.ts +++ b/src/client/eppo-precomputed-client.spec.ts @@ -12,7 +12,7 @@ import { ensureNonContextualSubjectAttributes, } from '../attributes'; import { IPrecomputedConfigurationResponse } from '../configuration'; -import { IConfigurationStore } from '../configuration-store/configuration-store'; +import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { DEFAULT_POLL_INTERVAL_MS, MAX_EVENT_QUEUE_SIZE, POLL_JITTER_PCT } from '../constants'; import FetchHttpClient from '../http-client'; @@ -20,6 +20,7 @@ import { FormatEnum, IObfuscatedPrecomputedBandit, PrecomputedFlag, + Variation, VariationType, } from '../interfaces'; import { decodeBase64, encodeBase64, getMD5Hash } from '../obfuscation'; @@ -1027,3 +1028,249 @@ describe('Precomputed Bandit Store', () => { loggerWarnSpy.mockRestore(); }); }); + +describe('flag overrides', () => { + let client: EppoPrecomputedClient; + let mockLogger: IAssignmentLogger; + let overrideStore: ISyncStore; + let flagStorage: IConfigurationStore; + let subject: Subject; + + const precomputedFlagKey = 'mock-flag'; + const hashedPrecomputedFlagKey = getMD5Hash(precomputedFlagKey); + + const mockPrecomputedFlag: PrecomputedFlag = { + flagKey: hashedPrecomputedFlagKey, + variationKey: encodeBase64('a'), + variationValue: encodeBase64('variation-a'), + allocationKey: encodeBase64('allocation-a'), + doLog: true, + variationType: VariationType.STRING, + extraLogging: {}, + }; + + beforeEach(() => { + flagStorage = new MemoryOnlyConfigurationStore(); + flagStorage.setEntries({ [hashedPrecomputedFlagKey]: mockPrecomputedFlag }); + mockLogger = td.object(); + overrideStore = new MemoryOnlyConfigurationStore(); + subject = { + subjectKey: 'test-subject', + subjectAttributes: { attr1: 'value1' }, + }; + + client = new EppoPrecomputedClient({ + precomputedFlagStore: flagStorage, + subject, + overrideStore, + }); + client.setAssignmentLogger(mockLogger); + }); + + it('returns override values for all supported types', () => { + overrideStore.setEntries({ + 'string-flag': { + key: 'override-variation', + value: 'override-string', + }, + 'boolean-flag': { + key: 'override-variation', + value: true, + }, + 'numeric-flag': { + key: 'override-variation', + value: 42.5, + }, + 'json-flag': { + key: 'override-variation', + value: '{"foo": "bar"}', + }, + }); + + expect(client.getStringAssignment('string-flag', 'default')).toBe('override-string'); + expect(client.getBooleanAssignment('boolean-flag', false)).toBe(true); + expect(client.getNumericAssignment('numeric-flag', 0)).toBe(42.5); + expect(client.getJSONAssignment('json-flag', {})).toEqual({ foo: 'bar' }); + }); + + it('does not log assignments when override is applied', () => { + overrideStore.setEntries({ + [precomputedFlagKey]: { + key: 'override-variation', + value: 'override-value', + }, + }); + + client.getStringAssignment(precomputedFlagKey, 'default'); + + expect(td.explain(mockLogger.logAssignment).callCount).toBe(0); + }); + + it('uses normal assignment when no override exists for flag', () => { + // Set override for a different flag + overrideStore.setEntries({ + 'other-flag': { + key: 'override-variation', + value: 'override-value', + }, + }); + + const result = client.getStringAssignment(precomputedFlagKey, 'default'); + + // Should get the normal assignment value from mockPrecomputedFlag + expect(result).toBe('variation-a'); + expect(td.explain(mockLogger.logAssignment).callCount).toBe(1); + }); + + it('uses normal assignment when no overrides store is configured', () => { + // Create client without overrides store + const clientWithoutOverrides = new EppoPrecomputedClient({ + precomputedFlagStore: flagStorage, + subject, + }); + clientWithoutOverrides.setAssignmentLogger(mockLogger); + + const result = clientWithoutOverrides.getStringAssignment(precomputedFlagKey, 'default'); + + // Should get the normal assignment value from mockPrecomputedFlag + expect(result).toBe('variation-a'); + expect(td.explain(mockLogger.logAssignment).callCount).toBe(1); + }); + + it('respects override after initial assignment without override', () => { + // First call without override + const initialAssignment = client.getStringAssignment(precomputedFlagKey, 'default'); + expect(initialAssignment).toBe('variation-a'); + expect(td.explain(mockLogger.logAssignment).callCount).toBe(1); + + // Set override and make second call + overrideStore.setEntries({ + [precomputedFlagKey]: { + key: 'override-variation', + value: 'override-value', + }, + }); + + const overriddenAssignment = client.getStringAssignment(precomputedFlagKey, 'default'); + expect(overriddenAssignment).toBe('override-value'); + // No additional logging should occur when using override + expect(td.explain(mockLogger.logAssignment).callCount).toBe(1); + }); + + it('reverts to normal assignment after removing override', () => { + // Set initial override + overrideStore.setEntries({ + [precomputedFlagKey]: { + key: 'override-variation', + value: 'override-value', + }, + }); + + const overriddenAssignment = client.getStringAssignment(precomputedFlagKey, 'default'); + expect(overriddenAssignment).toBe('override-value'); + expect(td.explain(mockLogger.logAssignment).callCount).toBe(0); + + // Remove override and make second call + overrideStore.setEntries({}); + + const normalAssignment = client.getStringAssignment(precomputedFlagKey, 'default'); + expect(normalAssignment).toBe('variation-a'); + // Should log the normal assignment + expect(td.explain(mockLogger.logAssignment).callCount).toBe(1); + }); + + describe('setOverrideStore', () => { + it('applies overrides after setting store', () => { + // Create client without overrides store + const clientWithoutOverrides = new EppoPrecomputedClient({ + precomputedFlagStore: flagStorage, + subject, + }); + clientWithoutOverrides.setAssignmentLogger(mockLogger); + + // Initial call without override store + const initialAssignment = clientWithoutOverrides.getStringAssignment( + precomputedFlagKey, + 'default', + ); + expect(initialAssignment).toBe('variation-a'); + expect(td.explain(mockLogger.logAssignment).callCount).toBe(1); + + // Set overrides store with override + overrideStore.setEntries({ + [precomputedFlagKey]: { + key: 'override-variation', + value: 'override-value', + }, + }); + clientWithoutOverrides.setOverrideStore(overrideStore); + + // Call after setting override store + const overriddenAssignment = clientWithoutOverrides.getStringAssignment( + precomputedFlagKey, + 'default', + ); + expect(overriddenAssignment).toBe('override-value'); + // No additional logging should occur when using override + expect(td.explain(mockLogger.logAssignment).callCount).toBe(1); + }); + + it('reverts to normal assignment after unsetting store', () => { + // Set initial override + overrideStore.setEntries({ + [precomputedFlagKey]: { + key: 'override-variation', + value: 'override-value', + }, + }); + + client.getStringAssignment(precomputedFlagKey, 'default'); + expect(td.explain(mockLogger.logAssignment).callCount).toBe(0); + + // Unset overrides store + client.unsetOverrideStore(); + + const normalAssignment = client.getStringAssignment(precomputedFlagKey, 'default'); + expect(normalAssignment).toBe('variation-a'); + // Should log the normal assignment + expect(td.explain(mockLogger.logAssignment).callCount).toBe(1); + }); + + it('switches between different override stores', () => { + // Create a second override store + const secondOverrideStore = new MemoryOnlyConfigurationStore(); + + // Set up different overrides in each store + overrideStore.setEntries({ + [precomputedFlagKey]: { + key: 'override-1', + value: 'value-1', + }, + }); + + secondOverrideStore.setEntries({ + [precomputedFlagKey]: { + key: 'override-2', + value: 'value-2', + }, + }); + + // Start with first override store + const firstOverride = client.getStringAssignment(precomputedFlagKey, 'default'); + expect(firstOverride).toBe('value-1'); + expect(td.explain(mockLogger.logAssignment).callCount).toBe(0); + + // Switch to second override store + client.setOverrideStore(secondOverrideStore); + const secondOverride = client.getStringAssignment(precomputedFlagKey, 'default'); + expect(secondOverride).toBe('value-2'); + expect(td.explain(mockLogger.logAssignment).callCount).toBe(0); + + // Switch back to first override store + client.setOverrideStore(overrideStore); + const backToFirst = client.getStringAssignment(precomputedFlagKey, 'default'); + expect(backToFirst).toBe('value-1'); + expect(td.explain(mockLogger.logAssignment).callCount).toBe(0); + }); + }); +}); diff --git a/src/client/eppo-precomputed-client.ts b/src/client/eppo-precomputed-client.ts index 6415552..72a843e 100644 --- a/src/client/eppo-precomputed-client.ts +++ b/src/client/eppo-precomputed-client.ts @@ -9,7 +9,7 @@ import { IBanditEvent, IBanditLogger } from '../bandit-logger'; import { AssignmentCache } from '../cache/abstract-assignment-cache'; import { LRUInMemoryAssignmentCache } from '../cache/lru-in-memory-assignment-cache'; import { NonExpiringInMemoryAssignmentCache } from '../cache/non-expiring-in-memory-cache-assignment'; -import { IConfigurationStore } from '../configuration-store/configuration-store'; +import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store'; import { DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES, DEFAULT_POLL_CONFIG_REQUEST_RETRIES, @@ -27,6 +27,7 @@ import { IObfuscatedPrecomputedBandit, PrecomputedFlag, VariationType, + Variation, } from '../interfaces'; import { getMD5Hash } from '../obfuscation'; import initPoller, { IPoller } from '../poller'; @@ -60,6 +61,7 @@ export type PrecomputedFlagsRequestParameters = { interface EppoPrecomputedClientOptions { precomputedFlagStore: IConfigurationStore; precomputedBanditStore?: IConfigurationStore; + overrideStore?: ISyncStore; subject: Subject; banditActions?: Record>; requestParameters?: PrecomputedFlagsRequestParameters; @@ -81,10 +83,13 @@ export default class EppoPrecomputedClient { private banditActions?: Record>; private precomputedFlagStore: IConfigurationStore; private precomputedBanditStore?: IConfigurationStore; + private overrideStore?: ISyncStore; public constructor(options: EppoPrecomputedClientOptions) { this.precomputedFlagStore = options.precomputedFlagStore; this.precomputedBanditStore = options.precomputedBanditStore; + this.overrideStore = options.overrideStore; + const { subjectKey, subjectAttributes } = options.subject; this.subject = { subjectKey, @@ -199,6 +204,11 @@ export default class EppoPrecomputedClient { ): T { validateNotBlank(flagKey, 'Invalid argument: flagKey cannot be blank'); + const overrideVariation = this.overrideStore?.get(flagKey); + if (overrideVariation) { + return valueTransformer(overrideVariation.value); + } + const precomputedFlag = this.getPrecomputedFlag(flagKey); if (precomputedFlag == null) { @@ -515,4 +525,12 @@ export default class EppoPrecomputedClient { sdkLibVersion: LIB_VERSION, }; } + + public setOverrideStore(store: ISyncStore): void { + this.overrideStore = store; + } + + public unsetOverrideStore(): void { + this.overrideStore = undefined; + } } diff --git a/src/evaluator.ts b/src/evaluator.ts index 452e3b9..ded3132 100644 --- a/src/evaluator.ts +++ b/src/evaluator.ts @@ -259,3 +259,34 @@ export function matchesRules( matchedRule: null, }; } + +export function overrideResult( + flagKey: string, + subjectKey: string, + subjectAttributes: Attributes, + overrideVariation: Variation, + flagEvaluationDetailsBuilder: FlagEvaluationDetailsBuilder, +): FlagEvaluation { + const overrideAllocationKey = 'override-' + overrideVariation.key; + const flagEvaluationDetails = flagEvaluationDetailsBuilder + .setMatch( + 0, + overrideVariation, + { key: overrideAllocationKey, splits: [], doLog: false }, + null, + undefined, + ) + .build('MATCH', 'Flag override applied'); + + return { + flagKey, + subjectKey, + variation: overrideVariation, + subjectAttributes, + flagEvaluationDetails, + doLog: false, + format: '', + allocationKey: overrideAllocationKey, + extraLogging: {}, + }; +}