Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
60fafe1
refactor: add ConfigurationStore abstraction
rasendubi Mar 20, 2025
68c8b2f
refactor: add PersistentConfigurationStorage interface
rasendubi Mar 20, 2025
b51b576
refactor: add Configuration type (temp aliased to IConfiguration)
rasendubi Mar 20, 2025
a491d0b
chore: add top-level .prettierrc
rasendubi Mar 20, 2025
9325c88
refactor: make evaluation use new ConfigurationStore
rasendubi Mar 20, 2025
c2a07c1
refactor: fix linter errors
rasendubi Mar 20, 2025
6df8767
refactor: add Configuration type
rasendubi Mar 20, 2025
4d3b534
refactor: make requestor work with new ConfigurationStore
rasendubi Mar 20, 2025
2f70a51
refactor: eppo client cleanup
rasendubi Mar 20, 2025
d4c6f06
refactor: remove temp configuration
rasendubi Mar 20, 2025
60c8617
refactor: don't fetch bandits if current model is up-to-date
rasendubi Mar 21, 2025
51f4abb
refactor: implement new initialization/poller behavior
rasendubi Mar 29, 2025
34ff6c3
refactor: introduce configuration feed
rasendubi Apr 4, 2025
a7da43d
Merge remote-tracking branch 'origin/main' into HEAD
rasendubi Apr 7, 2025
6b4725c
feat: extend Configuration/Requestor to support precomputed
rasendubi Apr 7, 2025
311f61f
feat: support precomputed config for flags evaluation
rasendubi Apr 7, 2025
e5e5b9f
feat: make EppoClient handle precomputed bandits
rasendubi Apr 11, 2025
9ee4834
feat: add Subject (subject-scoped client)
rasendubi Apr 23, 2025
3b79e68
feat: make getPrecomputedConfiguration return Configuration instead o…
rasendubi Apr 23, 2025
bef0393
refactor: remove old configuration store
rasendubi Apr 23, 2025
311364d
refactor: revise public/internal APIs
rasendubi Apr 23, 2025
9647e30
refactor: cleanup/prettier
rasendubi Apr 23, 2025
4c9a8f8
refactor: remove IConfigurationWire
rasendubi Apr 23, 2025
896ffb8
Merge remote-tracking branch 'origin/main' into HEAD
rasendubi Apr 23, 2025
a8866fb
refactor: re-run prettier
rasendubi Apr 23, 2025
6bea66d
refactor: remove ConfigDetails
rasendubi Apr 23, 2025
badd643
refactor: fix lints
rasendubi Apr 23, 2025
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
9 changes: 1 addition & 8 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,7 @@ module.exports = {
message: "'setImmediate' unavailable in JavaScript. Use 'setTimeout(fn, 0)' instead",
},
],
'prettier/prettier': [
'warn',
{
singleQuote: true,
trailingComma: 'all',
printWidth: 100,
},
],
'prettier/prettier': ['warn'],
'unused-imports/no-unused-imports': 'error',
},
overrides: [
Expand Down
5 changes: 5 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
Copy link
Collaborator

Choose a reason for hiding this comment

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

🧹

"singleQuote": true,
"trailingComma": "all",
"printWidth": 100
}
Binary file added docs/configuration-lifecycle.excalidraw.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
70 changes: 70 additions & 0 deletions docs/configuration-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Configuration Lifecycle

This document explains how configuration is managed throughout its lifecycle in the Eppo SDK.
Copy link
Contributor

Choose a reason for hiding this comment

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

🔥


## Components Overview

The SDK's configuration management is built around several key components that work together:

- **ConfigurationFeed**: A broadcast channel that serves as the central communication point between components
- **ConfigurationStore**: Maintains the currently active configuration used for all evaluations
- **ConfigurationPoller**: Periodically fetches new configurations from the Eppo API
- **PersistentConfigurationCache**: Persists configuration between application restarts

## Communication Flow

The ConfigurationFeed acts as a central hub through which different components communicate:

![](./configuration-lifecycle.excalidraw.png)

When a new configuration is received (either from network or cache), it's broadcast through the ConfigurationFeed. Components subscribe to this feed to react to configuration changes. Importantly, configurations broadcast on the ConfigurationFeed are not necessarily activated - they may never be activated at all, as they represent only the latest discovered configurations. For components interested in the currently active configuration, the ConfigurationStore provides its own broadcast channel that only emits when configurations become active.
Comment on lines +14 to +20
Copy link
Member

Choose a reason for hiding this comment

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

This is a really exciting new interface - something I want to support in the near future is a concept that the requested configuration is not going to be "complete": we might want to deliver updates via a websocket or the edge JSON will be paged. Either way having a separation between the network response being the canonical source of truth and the configuration store is very important.

Do you feel like the interface you are creating makes us read to push updated in this way? I am wondering if the broadcaster should have a more verbose API that tells listeners whether flags are being added or removed.


## Initialization Process

During initialization, the client:

1. **Configuration Loading Strategy**:
- `stale-while-revalidate`: Uses cached config if within `maxStaleSeconds`, while fetching fresh data
Copy link
Contributor

Choose a reason for hiding this comment

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

where do we specify what we do with the fetched data? Save for next time? Switch immediately?
I see that is covered later in "Configuration Activation" perhaps we should specify that here. "...while fetching fresh data to be activated per the configuration activation strategy"

- `only-if-cached`: Uses cached config without network requests
Copy link
Contributor

Choose a reason for hiding this comment

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

even if stale? If so we should put that in

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

oh, no, maxStaleSeconds is still respected

- `no-cache`: Always fetches fresh configuration
- `none`: Uses only initial configuration without loading/fetching

2. **Loading cached configuration**:
- If `initialConfiguration` is provided, uses it immediately
- Otherwise, tries to load cached configuration

3. **Network Fetching**:
- If fetching is needed, attempts to fetch until success or timeout
- Applies backoff with jitter between retry attempts (with shorter period than normal polling)
- Broadcasts fetched configuration via ConfigurationFeed

4. **Completion**:
- Initialization completes when either:
- Fresh configuration is fetched (for network strategies)
- Cache is loaded (for cache-only strategies)
Copy link
Contributor

Choose a reason for hiding this comment

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

or stale-while-revalidate right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

For stale-while-revalidate, the initialization completes when a fresh configuration is fetched

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh I thought that strategy means serve stale assignments and fetch and background? I think that would mean initialization should finish prior to fetch.

- Timeout is reached (using best available configuration)

## Ongoing Configuration Management

After initialization:

1. **Polling** (if enabled):
- ConfigurationPoller periodically fetches new configurations
- Uses exponential backoff with jitter for retries on failure
- Broadcasts new configurations through ConfigurationFeed

2. **Configuration Activation**:
- When ConfigurationStore receives new configurations, it activates them based on strategy:
- `always`: Activate immediately
- `stale`: Activate if current config exceeds `maxStaleSeconds`
- `empty`: Activate if current config is empty
- `next-load`: Store for next initialization

3. **Persistent Storage**:
- PersistentConfigurationCache listens to ConfigurationFeed
- Automatically stores new configurations to persistent storage
- Provides cached configurations on initialization

## Evaluation

For all feature flag evaluations, EppoClient always uses the currently active configuration from ConfigurationStore. This ensures consistent behavior even as configurations are updated in the background.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./internal": {
"types": "./dist/internal.d.ts",
"default": "./dist/internal.js"
Comment on lines +20 to +22
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Want to clearly delineate public and internal APIs, so @eppo/js-client-sdk-common/internal is intended for internal SDK usage only and should not be re-exposed to users.

}
},
"scripts": {
Expand Down
3 changes: 2 additions & 1 deletion src/application-logger.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import pino from 'pino';

/** @internal */
export const loggerPrefix = '[Eppo SDK]';

// Create a Pino logger instance
/** @internal */
export const logger = pino({
// eslint-disable-next-line no-restricted-globals
level: process.env.LOG_LEVEL ?? (process.env.NODE_ENV === 'production' ? 'warn' : 'info'),
Expand Down
32 changes: 32 additions & 0 deletions src/broadcast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export type Listener<T extends unknown[]> = (...args: T) => void;

/**
* A broadcast channel for dispatching events to multiple listeners.
*
* @internal
*/
export class BroadcastChannel<T extends unknown[]> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Any particular reason you're rolling your own vs. using a multi-js-environment third party library like eventemitter3?

This seems simple enough and keeps dependencies down, which are advantages so I'm cool rolling with this just curious if you had thoughts about the pros and cons.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Wanted it to have proper typescript support and be lightweight (eventemitter3 is neither + unmaintained). Couldn't find a good library quickly, so it was easier to roll my own

Copy link
Contributor

Choose a reason for hiding this comment

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

makes sense!

private listeners: Array<Listener<T>> = [];

public addListener(listener: Listener<T>): () => void {
this.listeners.push(listener);
return () => this.removeListener(listener);
}

public removeListener(listener: Listener<T>): void {
const idx = this.listeners.indexOf(listener);
if (idx !== -1) {
this.listeners.splice(idx, 1);
}
}

public broadcast(...args: T): void {
for (const listener of this.listeners) {
try {
listener(...args);
} catch {
// ignore
}
}
}
}
56 changes: 11 additions & 45 deletions src/client/eppo-client-assignment-details.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,32 @@ import * as fs from 'fs';
import {
AssignmentVariationValue,
IAssignmentTestCase,
MOCK_UFC_RESPONSE_FILE,
readMockUFCResponse,
readMockUfcConfiguration,
} from '../../test/testHelpers';
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
import { AllocationEvaluationCode } from '../flag-evaluation-details-builder';
import { Flag, ObfuscatedFlag, Variation, VariationType } from '../interfaces';
import { Variation, VariationType } from '../interfaces';
import { OperatorType } from '../rules';
import { AttributeType } from '../types';

import EppoClient, { IAssignmentDetails } from './eppo-client';
import { initConfiguration } from './test-utils';

describe('EppoClient get*AssignmentDetails', () => {
const testStart = Date.now();

global.fetch = jest.fn(() => {
const ufc = readMockUFCResponse(MOCK_UFC_RESPONSE_FILE);
let client: EppoClient;

return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(ufc),
beforeEach(() => {
client = new EppoClient({
Copy link
Contributor

Choose a reason for hiding this comment

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

🙌
Great call hoisting this up

sdkKey: 'dummy',
sdkName: 'js-client-sdk-common',
sdkVersion: '1.0.0',
baseUrl: 'http://127.0.0.1:4000',
configuration: { initialConfiguration: readMockUfcConfiguration() },
});
}) as jest.Mock;
const storage = new MemoryOnlyConfigurationStore<Flag | ObfuscatedFlag>();

beforeAll(async () => {
await initConfiguration(storage);
client.setIsGracefulFailureMode(false);
});

it('should set the details for a matched rule', () => {
const client = new EppoClient({ flagConfigurationStore: storage });
client.setIsGracefulFailureMode(false);
const subjectAttributes = { email: '[email protected]', country: 'US' };
const result = client.getIntegerAssignmentDetails(
'integer-flag',
Expand Down Expand Up @@ -86,8 +79,6 @@ describe('EppoClient get*AssignmentDetails', () => {
});

it('should set the details for a matched split', () => {
const client = new EppoClient({ flagConfigurationStore: storage });
client.setIsGracefulFailureMode(false);
const subjectAttributes = { email: '[email protected]', country: 'Brazil' };
const result = client.getIntegerAssignmentDetails(
'integer-flag',
Expand Down Expand Up @@ -129,8 +120,6 @@ describe('EppoClient get*AssignmentDetails', () => {
});

it('should handle matching a split allocation with a matched rule', () => {
const client = new EppoClient({ flagConfigurationStore: storage });
client.setIsGracefulFailureMode(false);
const subjectAttributes = { id: 'alice', email: '[email protected]', country: 'Brazil' };
const result = client.getStringAssignmentDetails(
'new-user-onboarding',
Expand Down Expand Up @@ -191,8 +180,6 @@ describe('EppoClient get*AssignmentDetails', () => {
});

it('should handle unrecognized flags', () => {
const client = new EppoClient({ flagConfigurationStore: storage });
client.setIsGracefulFailureMode(false);
const result = client.getIntegerAssignmentDetails('asdf', 'alice', {}, 0);
expect(result).toEqual({
variation: 0,
Expand All @@ -216,7 +203,6 @@ describe('EppoClient get*AssignmentDetails', () => {
});

it('should handle type mismatches with graceful failure mode enabled', () => {
const client = new EppoClient({ flagConfigurationStore: storage });
client.setIsGracefulFailureMode(true);
const result = client.getBooleanAssignmentDetails('integer-flag', 'alice', {}, true);
expect(result).toEqual({
Expand Down Expand Up @@ -253,7 +239,6 @@ describe('EppoClient get*AssignmentDetails', () => {
});

it('should throw an error for type mismatches with graceful failure mode disabled', () => {
const client = new EppoClient({ flagConfigurationStore: storage });
client.setIsGracefulFailureMode(false);
expect(() => client.getBooleanAssignmentDetails('integer-flag', 'alice', {}, true)).toThrow();
});
Expand All @@ -278,22 +263,6 @@ describe('EppoClient get*AssignmentDetails', () => {
}
};

beforeAll(async () => {
global.fetch = jest.fn(() => {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(readMockUFCResponse(MOCK_UFC_RESPONSE_FILE)),
});
}) as jest.Mock;

await initConfiguration(storage);
});

afterAll(() => {
jest.restoreAllMocks();
});

describe.each(getTestFilePaths())('for file: %s', (testFilePath: string) => {
const testCase = parseJSON(testFilePath);
describe.each(testCase.subjects.map(({ subjectKey }) => subjectKey))(
Expand All @@ -303,9 +272,6 @@ describe('EppoClient get*AssignmentDetails', () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const subject = subjects.find((subject) => subject.subjectKey === subjectKey)!;

const client = new EppoClient({ flagConfigurationStore: storage });
client.setIsGracefulFailureMode(false);

const focusOn = {
testFilePath: '', // focus on test file paths (don't forget to set back to empty string!)
subjectKey: '', // focus on subject (don't forget to set back to empty string!)
Expand Down
27 changes: 11 additions & 16 deletions src/client/eppo-client-experiment-container.spec.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,11 @@
import { MOCK_UFC_RESPONSE_FILE, readMockUFCResponse } from '../../test/testHelpers';
import { readMockUfcConfiguration } from '../../test/testHelpers';
import * as applicationLogger from '../application-logger';
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
import { Flag, ObfuscatedFlag } from '../interfaces';

import EppoClient, { IContainerExperiment } from './eppo-client';
import { initConfiguration } from './test-utils';

type Container = { name: string };

describe('getExperimentContainerEntry', () => {
global.fetch = jest.fn(() => {
const ufc = readMockUFCResponse(MOCK_UFC_RESPONSE_FILE);
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(ufc),
});
}) as jest.Mock;

const controlContainer: Container = { name: 'Control Container' };
const treatment1Container: Container = { name: 'Treatment Variation 1 Container' };
const treatment2Container: Container = { name: 'Treatment Variation 2 Container' };
Expand All @@ -29,9 +17,16 @@ describe('getExperimentContainerEntry', () => {
let loggerWarnSpy: jest.SpyInstance;

beforeEach(async () => {
const storage = new MemoryOnlyConfigurationStore<Flag | ObfuscatedFlag>();
await initConfiguration(storage);
client = new EppoClient({ flagConfigurationStore: storage });
client = new EppoClient({
configuration: {
initializationStrategy: 'none',
initialConfiguration: readMockUfcConfiguration(),
},
sdkKey: 'dummy',
sdkName: 'js-client-sdk-common',
sdkVersion: '1.0.0',
baseUrl: 'http://127.0.0.1:4000',
});
client.setIsGracefulFailureMode(true);
flagExperiment = {
flagKey: 'my-key',
Expand Down
Loading
Loading