Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add local override functionality #184

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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', () => {
yfrancis marked this conversation as resolved.
Show resolved Hide resolved
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' });
yfrancis marked this conversation as resolved.
Show resolved Hide resolved
});

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 @@ -177,6 +190,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 @@ -925,6 +956,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,
);
}

yfrancis marked this conversation as resolved.
Show resolved Hide resolved
const configDetails = this.getConfigDetails();
const flag = this.getFlag(flagKey);

Expand Down
Loading
Loading