Skip to content

Commit 0466712

Browse files
aarsilvclaude
andcommitted
Add offlineInit() for synchronous SDK initialization without network
Adds the ability to initialize the SDK with pre-fetched configuration JSON, enabling: - Synchronous initialization without network requests - Use cases where configuration is bootstrapped from another source - Edge/serverless environments where polling isn't desired Also includes: - IOfflineClientConfig interface for offline initialization options - DEFAULT_ASSIGNMENT_CACHE_SIZE constant for consistent cache sizing - Deprecation annotations on event tracking (discontinued feature) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7396a8f commit 0466712

3 files changed

Lines changed: 488 additions & 8 deletions

File tree

src/i-client-config.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ export interface IClientConfig {
5252
/** Amount of time in milliseconds to wait between API calls to refresh configuration data. Default of 30_000 (30s). */
5353
pollingIntervalMs?: number;
5454

55-
/** Configuration settings for the event dispatcher */
55+
/**
56+
* Configuration settings for the event dispatcher.
57+
* @deprecated Eppo has discontinued eventing support. Event tracking will be handled by Datadog SDKs.
58+
*/
5659
eventTracking?: {
5760
/** Maximum number of events to send per delivery request. Defaults to 1000 events. */
5861
batchSize?: number;
@@ -74,3 +77,55 @@ export interface IClientConfig {
7477
retryIntervalMs?: number;
7578
};
7679
}
80+
81+
/**
82+
* Configuration used for offline initialization of the Eppo client.
83+
* Offline initialization allows the SDK to be used without making any network requests.
84+
* @public
85+
*/
86+
export interface IOfflineClientConfig {
87+
/**
88+
* The full flags configuration JSON string as returned by the Eppo API.
89+
* This should be the complete response from the /flag-config/v1/config endpoint.
90+
*
91+
* Expected format:
92+
* ```json
93+
* {
94+
* "createdAt": "2024-04-17T19:40:53.716Z",
95+
* "format": "SERVER",
96+
* "environment": { "name": "production" },
97+
* "flags": { ... }
98+
* }
99+
* ```
100+
*/
101+
flagsConfiguration: string;
102+
103+
/**
104+
* Optional bandit models configuration JSON string as returned by the Eppo API.
105+
* This should be the complete response from the bandit parameters endpoint.
106+
*
107+
* Expected format:
108+
* ```json
109+
* {
110+
* "updatedAt": "2024-04-17T19:40:53.716Z",
111+
* "bandits": { ... }
112+
* }
113+
* ```
114+
*/
115+
banditsConfiguration?: string;
116+
117+
/**
118+
* Optional assignment logger for sending variation assignments to your data warehouse.
119+
* Required for experiment analysis.
120+
*/
121+
assignmentLogger?: IAssignmentLogger;
122+
123+
/** Optional bandit logger for sending bandit actions to your data warehouse */
124+
banditLogger?: IBanditLogger;
125+
126+
/**
127+
* Whether to throw an error if initialization fails. (default: true)
128+
* If false, the client will be initialized with an empty configuration.
129+
*/
130+
throwOnFailedInitialization?: boolean;
131+
}

src/index.spec.ts

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
IAssignmentLogger,
4141
init,
4242
NO_OP_EVENT_DISPATCHER,
43+
offlineInit,
4344
} from '.';
4445

4546
import SpyInstance = jest.SpyInstance;
@@ -913,3 +914,286 @@ describe('EppoClient E2E test', () => {
913914
});
914915
});
915916
});
917+
918+
describe('offlineInit', () => {
919+
const flagKey = 'mock-experiment';
920+
921+
// Configuration for a single flag within the UFC.
922+
const mockUfcFlagConfig: Flag = {
923+
key: flagKey,
924+
enabled: true,
925+
variationType: VariationType.STRING,
926+
variations: {
927+
control: {
928+
key: 'control',
929+
value: 'control',
930+
},
931+
'variant-1': {
932+
key: 'variant-1',
933+
value: 'variant-1',
934+
},
935+
'variant-2': {
936+
key: 'variant-2',
937+
value: 'variant-2',
938+
},
939+
},
940+
allocations: [
941+
{
942+
key: 'traffic-split',
943+
rules: [],
944+
splits: [
945+
{
946+
variationKey: 'control',
947+
shards: [
948+
{
949+
salt: 'some-salt',
950+
ranges: [{ start: 0, end: 3400 }],
951+
},
952+
],
953+
},
954+
{
955+
variationKey: 'variant-1',
956+
shards: [
957+
{
958+
salt: 'some-salt',
959+
ranges: [{ start: 3400, end: 6700 }],
960+
},
961+
],
962+
},
963+
{
964+
variationKey: 'variant-2',
965+
shards: [
966+
{
967+
salt: 'some-salt',
968+
ranges: [{ start: 6700, end: 10000 }],
969+
},
970+
],
971+
},
972+
],
973+
doLog: true,
974+
},
975+
],
976+
totalShards: 10000,
977+
};
978+
979+
// Helper to create a full configuration JSON string
980+
const createFlagsConfigJson = (
981+
flags: Record<string, Flag>,
982+
options: { createdAt?: string; format?: string } = {},
983+
): string => {
984+
return JSON.stringify({
985+
createdAt: options.createdAt ?? '2024-04-17T19:40:53.716Z',
986+
format: options.format ?? 'SERVER',
987+
environment: { name: 'Test' },
988+
flags,
989+
});
990+
};
991+
992+
describe('basic initialization', () => {
993+
it('initializes with flag configurations and returns correct assignments', () => {
994+
const client = offlineInit({
995+
flagsConfiguration: createFlagsConfigJson({ [flagKey]: mockUfcFlagConfig }),
996+
});
997+
998+
// subject-10 should get variant-1 based on the hash
999+
const assignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value');
1000+
expect(assignment).toEqual('variant-1');
1001+
});
1002+
1003+
it('returns default value when flag is not found', () => {
1004+
const client = offlineInit({
1005+
flagsConfiguration: createFlagsConfigJson({ [flagKey]: mockUfcFlagConfig }),
1006+
});
1007+
1008+
const assignment = client.getStringAssignment(
1009+
'non-existent-flag',
1010+
'subject-10',
1011+
{},
1012+
'default-value',
1013+
);
1014+
expect(assignment).toEqual('default-value');
1015+
});
1016+
1017+
it('initializes with empty configuration', () => {
1018+
const client = offlineInit({
1019+
flagsConfiguration: createFlagsConfigJson({}),
1020+
});
1021+
1022+
const assignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value');
1023+
expect(assignment).toEqual('default-value');
1024+
});
1025+
1026+
it('makes client available via getInstance()', () => {
1027+
offlineInit({
1028+
flagsConfiguration: createFlagsConfigJson({ [flagKey]: mockUfcFlagConfig }),
1029+
});
1030+
1031+
const client = getInstance();
1032+
const assignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value');
1033+
expect(assignment).toEqual('variant-1');
1034+
});
1035+
});
1036+
1037+
describe('assignment logging', () => {
1038+
it('logs assignments when assignment logger is provided', () => {
1039+
const mockLogger = td.object<IAssignmentLogger>();
1040+
1041+
const client = offlineInit({
1042+
flagsConfiguration: createFlagsConfigJson({ [flagKey]: mockUfcFlagConfig }),
1043+
assignmentLogger: mockLogger,
1044+
});
1045+
1046+
client.getStringAssignment(flagKey, 'subject-10', { foo: 'bar' }, 'default-value');
1047+
1048+
expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1);
1049+
const loggedAssignment = td.explain(mockLogger.logAssignment).calls[0].args[0];
1050+
expect(loggedAssignment.subject).toEqual('subject-10');
1051+
expect(loggedAssignment.featureFlag).toEqual(flagKey);
1052+
expect(loggedAssignment.allocation).toEqual('traffic-split');
1053+
});
1054+
1055+
it('does not throw when assignment logger throws', () => {
1056+
const mockLogger = td.object<IAssignmentLogger>();
1057+
td.when(mockLogger.logAssignment(td.matchers.anything())).thenThrow(
1058+
new Error('logging error'),
1059+
);
1060+
1061+
const client = offlineInit({
1062+
flagsConfiguration: createFlagsConfigJson({ [flagKey]: mockUfcFlagConfig }),
1063+
assignmentLogger: mockLogger,
1064+
});
1065+
1066+
// Should not throw, even though logger throws
1067+
const assignment = client.getStringAssignment(flagKey, 'subject-10', {}, 'default-value');
1068+
expect(assignment).toEqual('variant-1');
1069+
});
1070+
});
1071+
1072+
describe('configuration metadata', () => {
1073+
it('extracts createdAt from configuration as configPublishedAt', () => {
1074+
const createdAt = '2024-01-15T10:00:00.000Z';
1075+
1076+
const client = offlineInit({
1077+
flagsConfiguration: createFlagsConfigJson({ [flagKey]: mockUfcFlagConfig }, { createdAt }),
1078+
});
1079+
1080+
const result = client.getStringAssignmentDetails(flagKey, 'subject-10', {}, 'default-value');
1081+
expect(result.evaluationDetails.configPublishedAt).toBe(createdAt);
1082+
});
1083+
});
1084+
1085+
describe('error handling', () => {
1086+
it('throws error by default when JSON parsing fails', () => {
1087+
expect(() => {
1088+
offlineInit({
1089+
flagsConfiguration: 'invalid json',
1090+
});
1091+
}).toThrow();
1092+
});
1093+
1094+
it('does not throw when throwOnFailedInitialization is false', () => {
1095+
expect(() => {
1096+
offlineInit({
1097+
flagsConfiguration: 'invalid json',
1098+
throwOnFailedInitialization: false,
1099+
});
1100+
}).not.toThrow();
1101+
});
1102+
1103+
it('does not throw with valid empty flags configuration', () => {
1104+
expect(() => {
1105+
offlineInit({
1106+
flagsConfiguration: createFlagsConfigJson({}),
1107+
});
1108+
}).not.toThrow();
1109+
});
1110+
});
1111+
1112+
describe('no network requests', () => {
1113+
it('does not have configurationRequestParameters (no polling)', () => {
1114+
const client = offlineInit({
1115+
flagsConfiguration: createFlagsConfigJson({ [flagKey]: mockUfcFlagConfig }),
1116+
});
1117+
1118+
// Access the internal configurationRequestParameters - should be undefined for offline mode
1119+
const configurationRequestParameters = client['configurationRequestParameters'];
1120+
expect(configurationRequestParameters).toBeUndefined();
1121+
});
1122+
});
1123+
1124+
describe('bandit support', () => {
1125+
it('initializes with bandit references from configuration', () => {
1126+
const banditFlagKey = 'bandit-flag';
1127+
const banditKey = 'test-bandit';
1128+
1129+
const banditFlagConfig: Flag = {
1130+
key: banditFlagKey,
1131+
enabled: true,
1132+
variationType: VariationType.STRING,
1133+
variations: {
1134+
bandit: {
1135+
key: 'bandit',
1136+
value: 'bandit',
1137+
},
1138+
control: {
1139+
key: 'control',
1140+
value: 'control',
1141+
},
1142+
},
1143+
allocations: [
1144+
{
1145+
key: 'bandit-allocation',
1146+
rules: [],
1147+
splits: [
1148+
{
1149+
variationKey: 'bandit',
1150+
shards: [
1151+
{
1152+
salt: 'salt',
1153+
ranges: [{ start: 0, end: 10000 }],
1154+
},
1155+
],
1156+
},
1157+
],
1158+
doLog: true,
1159+
},
1160+
],
1161+
totalShards: 10000,
1162+
};
1163+
1164+
// Create a configuration with bandit references
1165+
const flagsConfigJson = JSON.stringify({
1166+
createdAt: '2024-04-17T19:40:53.716Z',
1167+
format: 'SERVER',
1168+
environment: { name: 'Test' },
1169+
flags: { [banditFlagKey]: banditFlagConfig },
1170+
banditReferences: {
1171+
[banditKey]: {
1172+
modelVersion: 'v1',
1173+
flagVariations: [
1174+
{
1175+
key: 'bandit',
1176+
flagKey: banditFlagKey,
1177+
variationKey: 'bandit',
1178+
variationValue: 'bandit',
1179+
},
1180+
],
1181+
},
1182+
},
1183+
});
1184+
1185+
const client = offlineInit({
1186+
flagsConfiguration: flagsConfigJson,
1187+
});
1188+
1189+
// Verify the client is initialized and can make assignments
1190+
const assignment = client.getStringAssignment(
1191+
banditFlagKey,
1192+
'subject-1',
1193+
{},
1194+
'default-value',
1195+
);
1196+
expect(assignment).toEqual('bandit');
1197+
});
1198+
});
1199+
});

0 commit comments

Comments
 (0)