Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
224 changes: 223 additions & 1 deletion src/client/eppo-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
validateTestAssignments,
} from '../../test/testHelpers';
import { IAssignmentLogger } from '../assignment-logger';
import { AssignmentCache } from '../cache/abstract-assignment-cache';
import {
IConfigurationWire,
IObfuscatedPrecomputedConfigurationResponse,
Expand All @@ -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';

Expand Down Expand Up @@ -945,4 +946,225 @@ describe('EppoClient E2E test', () => {
);
});
});

describe('flag overrides', () => {
let client: EppoClient;
let mockLogger: IAssignmentLogger;
let overrideStore: IConfigurationStore<Variation>;

beforeEach(() => {
storage.setEntries({ [flagKey]: mockFlag });
mockLogger = td.object<IAssignmentLogger>();
overrideStore = new MemoryOnlyConfigurationStore<Variation>();
client = new EppoClient({
flagConfigurationStore: storage,
overrideStore: overrideStore,
});
client.setAssignmentLogger(mockLogger);
client.useNonExpiringInMemoryAssignmentCache();
});

it('returns override values for all supported types', () => {
overrideStore.setEntries({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upstream overrideStore is not exposed, so I imagine we need a setOverride() method on the client to add an entry to this store, if present.

'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<AssignmentCache>();
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',
});
});
});
});
48 changes: 45 additions & 3 deletions src/client/eppo-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -116,6 +116,7 @@ export default class EppoClient {
private configurationRequestParameters?: FlagConfigurationRequestParameters;
private banditModelConfigurationStore?: IConfigurationStore<BanditParameters>;
private banditVariationConfigurationStore?: IConfigurationStore<BanditVariation[]>;
private overrideStore?: ISyncStore<Variation>;
private flagConfigurationStore: IConfigurationStore<Flag | ObfuscatedFlag>;
private assignmentLogger?: IAssignmentLogger;
private assignmentCache?: AssignmentCache;
Expand All @@ -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<Flag | ObfuscatedFlag>;
banditVariationConfigurationStore?: IConfigurationStore<BanditVariation[]>;
banditModelConfigurationStore?: IConfigurationStore<BanditParameters>;
overrideStore?: ISyncStore<Variation>;
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;
}
Expand Down Expand Up @@ -192,6 +205,24 @@ export default class EppoClient {
this.isObfuscated = isObfuscated;
}

setOverrideStore(store: ISyncStore<Variation>): void {
this.overrideStore = store;
}

unsetOverrideStore(): void {
this.overrideStore = undefined;
}

// Returns a mapping of flag key to variation key for all active overrides
getOverrideVariationKeys(): Record<string, string> {
return Object.fromEntries(
Object.entries(this.overrideStore?.entries() ?? {}).map(([flagKey, value]) => [
flagKey,
value.key,
]),
);
}

async fetchFlagConfigurations() {
if (!this.configurationRequestParameters) {
throw new Error(
Expand Down Expand Up @@ -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);

Expand Down
Loading
Loading