Skip to content

Commit 9ee4834

Browse files
committed
feat: add Subject (subject-scoped client)
1 parent e5e5b9f commit 9ee4834

File tree

2 files changed

+343
-0
lines changed

2 files changed

+343
-0
lines changed

src/client/eppo-client.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,11 @@ export default class EppoClient {
335335
private readonly configurationPoller: ConfigurationPoller;
336336
private initialized = false;
337337
private readonly initializationPromise: Promise<void>;
338+
private readonly precomputedConfig?: {
339+
subjectKey: string;
340+
subjectAttributes: ContextAttributes;
341+
banditActions?: Record<FlagKey, BanditActions>;
342+
};
338343

339344
constructor(options: EppoClientParameters) {
340345
const { eventDispatcher = new NoOpEventDispatcher(), overrideStore, configuration } = options;
@@ -360,6 +365,17 @@ export default class EppoClient {
360365
} = {},
361366
} = options;
362367

368+
// Store precomputed config options for later use in getPrecomputedSubject().
369+
if (options.configuration?.precompute) {
370+
this.precomputedConfig = {
371+
subjectKey: options.configuration.precompute.subjectKey,
372+
subjectAttributes: ensureContextualSubjectAttributes(
373+
options.configuration.precompute.subjectAttributes,
374+
),
375+
banditActions: options.configuration.precompute.banditActions,
376+
};
377+
}
378+
363379
this.configurationFeed = new BroadcastChannel<[Configuration, ConfigurationSource]>();
364380

365381
this.configurationStore = new ConfigurationStore(configuration?.initialConfiguration);
@@ -558,6 +574,48 @@ export default class EppoClient {
558574
return this.configurationStore.onConfigurationChange(listener);
559575
}
560576

577+
/**
578+
* Creates a Subject-scoped instance.
579+
*
580+
* This is useful if you need to evaluate multiple assignments for the same subject. Returned
581+
* Subject is connected to the EppoClient instance and will use the same configuration.
582+
*/
583+
public getSubject(
584+
subjectKey: string,
585+
subjectAttributes: Attributes | ContextAttributes = {},
586+
banditActions: Record<FlagKey, BanditActions> = {},
587+
): Subject {
588+
return new Subject(this, subjectKey, subjectAttributes, banditActions);
589+
}
590+
591+
/**
592+
* If the client is configured to precompute, returns a Subject-scoped instance for the
593+
* precomputed configuration.
594+
*/
595+
public getPrecomputedSubject(): Subject | undefined {
596+
const configuration = this.getConfiguration();
597+
const precomputed = configuration.getPrecomputedConfiguration();
598+
599+
if (precomputed) {
600+
return this.getSubject(
601+
precomputed.subjectKey,
602+
precomputed.subjectAttributes ?? {},
603+
precomputed.banditActions,
604+
);
605+
}
606+
607+
// Use the stored precomputed config if available and configuration hasn't been loaded yet
608+
if (this.precomputedConfig) {
609+
return this.getSubject(
610+
this.precomputedConfig.subjectKey,
611+
this.precomputedConfig.subjectAttributes,
612+
this.precomputedConfig.banditActions,
613+
);
614+
}
615+
616+
return undefined;
617+
}
618+
561619
/**
562620
* Validates and parses x-eppo-overrides header sent by Eppo's Chrome extension
563621
*/

src/client/subject.ts

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
import EppoClient, { IAssignmentDetails, IContainerExperiment } from './eppo-client';
2+
import { Attributes, BanditActions, ContextAttributes, FlagKey } from '../types';
3+
import { ensureNonContextualSubjectAttributes } from '../attributes';
4+
import { Configuration } from '../configuration';
5+
6+
/**
7+
* A wrapper around EppoClient that automatically supplies subject key, attributes, and bandit
8+
* actions for all assignment and bandit methods.
9+
*
10+
* This is useful when you always want to use the same subject and attributes for all flag
11+
* evaluations.
12+
*/
13+
export class Subject {
14+
private client: EppoClient;
15+
private subjectKey: string;
16+
private subjectAttributes: Attributes | ContextAttributes;
17+
private banditActions?: Record<FlagKey, BanditActions>;
18+
19+
/**
20+
* @internal Creates a new Subject instance.
21+
*
22+
* @param client The EppoClient instance to wrap
23+
* @param subjectKey The subject key to use for all assignments
24+
* @param subjectAttributes The subject attributes to use for all assignments
25+
* @param banditActions Optional default bandit actions to use for all bandit evaluations
26+
*/
27+
constructor(
28+
client: EppoClient,
29+
subjectKey: string,
30+
subjectAttributes: Attributes | ContextAttributes,
31+
banditActions: Record<FlagKey, BanditActions>
32+
) {
33+
this.client = client;
34+
this.subjectKey = subjectKey;
35+
this.subjectAttributes = subjectAttributes;
36+
this.banditActions = banditActions;
37+
}
38+
39+
/**
40+
* Gets the underlying EppoClient instance.
41+
*/
42+
public getClient(): EppoClient {
43+
return this.client;
44+
}
45+
46+
/**
47+
* Maps a subject to a string variation for a given experiment.
48+
*
49+
* @param flagKey feature flag identifier
50+
* @param defaultValue default value to return if the subject is not part of the experiment sample
51+
* @returns a variation value if the subject is part of the experiment sample, otherwise the default value
52+
*/
53+
public getStringAssignment(flagKey: string, defaultValue: string): string {
54+
return this.client.getStringAssignment(
55+
flagKey,
56+
this.subjectKey,
57+
ensureNonContextualSubjectAttributes(this.subjectAttributes),
58+
defaultValue
59+
);
60+
}
61+
62+
/**
63+
* Maps a subject to a string variation for a given experiment and provides additional details about the
64+
* variation assigned and the reason for the assignment.
65+
*
66+
* @param flagKey feature flag identifier
67+
* @param defaultValue default value to return if the subject is not part of the experiment sample
68+
* @returns an object that includes the variation value along with additional metadata about the assignment
69+
*/
70+
public getStringAssignmentDetails(flagKey: string, defaultValue: string): IAssignmentDetails<string> {
71+
return this.client.getStringAssignmentDetails(
72+
flagKey,
73+
this.subjectKey,
74+
ensureNonContextualSubjectAttributes(this.subjectAttributes),
75+
defaultValue
76+
);
77+
}
78+
79+
/**
80+
* Maps a subject to a boolean variation for a given experiment.
81+
*
82+
* @param flagKey feature flag identifier
83+
* @param defaultValue default value to return if the subject is not part of the experiment sample
84+
* @returns a boolean variation value if the subject is part of the experiment sample, otherwise the default value
85+
*/
86+
public getBooleanAssignment(flagKey: string, defaultValue: boolean): boolean {
87+
return this.client.getBooleanAssignment(
88+
flagKey,
89+
this.subjectKey,
90+
ensureNonContextualSubjectAttributes(this.subjectAttributes),
91+
defaultValue
92+
);
93+
}
94+
95+
/**
96+
* Maps a subject to a boolean variation for a given experiment and provides additional details about the
97+
* variation assigned and the reason for the assignment.
98+
*
99+
* @param flagKey feature flag identifier
100+
* @param defaultValue default value to return if the subject is not part of the experiment sample
101+
* @returns an object that includes the variation value along with additional metadata about the assignment
102+
*/
103+
public getBooleanAssignmentDetails(flagKey: string, defaultValue: boolean): IAssignmentDetails<boolean> {
104+
return this.client.getBooleanAssignmentDetails(
105+
flagKey,
106+
this.subjectKey,
107+
ensureNonContextualSubjectAttributes(this.subjectAttributes),
108+
defaultValue
109+
);
110+
}
111+
112+
/**
113+
* Maps a subject to an Integer variation for a given experiment.
114+
*
115+
* @param flagKey feature flag identifier
116+
* @param defaultValue default value to return if the subject is not part of the experiment sample
117+
* @returns an integer variation value if the subject is part of the experiment sample, otherwise the default value
118+
*/
119+
public getIntegerAssignment(flagKey: string, defaultValue: number): number {
120+
return this.client.getIntegerAssignment(
121+
flagKey,
122+
this.subjectKey,
123+
ensureNonContextualSubjectAttributes(this.subjectAttributes),
124+
defaultValue
125+
);
126+
}
127+
128+
/**
129+
* Maps a subject to an Integer variation for a given experiment and provides additional details about the
130+
* variation assigned and the reason for the assignment.
131+
*
132+
* @param flagKey feature flag identifier
133+
* @param defaultValue default value to return if the subject is not part of the experiment sample
134+
* @returns an object that includes the variation value along with additional metadata about the assignment
135+
*/
136+
public getIntegerAssignmentDetails(flagKey: string, defaultValue: number): IAssignmentDetails<number> {
137+
return this.client.getIntegerAssignmentDetails(
138+
flagKey,
139+
this.subjectKey,
140+
ensureNonContextualSubjectAttributes(this.subjectAttributes),
141+
defaultValue
142+
);
143+
}
144+
145+
/**
146+
* Maps a subject to a numeric variation for a given experiment.
147+
*
148+
* @param flagKey feature flag identifier
149+
* @param defaultValue default value to return if the subject is not part of the experiment sample
150+
* @returns a number variation value if the subject is part of the experiment sample, otherwise the default value
151+
*/
152+
public getNumericAssignment(flagKey: string, defaultValue: number): number {
153+
return this.client.getNumericAssignment(
154+
flagKey,
155+
this.subjectKey,
156+
ensureNonContextualSubjectAttributes(this.subjectAttributes),
157+
defaultValue
158+
);
159+
}
160+
161+
/**
162+
* Maps a subject to a numeric variation for a given experiment and provides additional details about the
163+
* variation assigned and the reason for the assignment.
164+
*
165+
* @param flagKey feature flag identifier
166+
* @param defaultValue default value to return if the subject is not part of the experiment sample
167+
* @returns an object that includes the variation value along with additional metadata about the assignment
168+
*/
169+
public getNumericAssignmentDetails(flagKey: string, defaultValue: number): IAssignmentDetails<number> {
170+
return this.client.getNumericAssignmentDetails(
171+
flagKey,
172+
this.subjectKey,
173+
ensureNonContextualSubjectAttributes(this.subjectAttributes),
174+
defaultValue
175+
);
176+
}
177+
178+
/**
179+
* Maps a subject to a JSON variation for a given experiment.
180+
*
181+
* @param flagKey feature flag identifier
182+
* @param defaultValue default value to return if the subject is not part of the experiment sample
183+
* @returns a JSON object variation value if the subject is part of the experiment sample, otherwise the default value
184+
*/
185+
public getJSONAssignment(flagKey: string, defaultValue: object): object {
186+
return this.client.getJSONAssignment(
187+
flagKey,
188+
this.subjectKey,
189+
ensureNonContextualSubjectAttributes(this.subjectAttributes),
190+
defaultValue
191+
);
192+
}
193+
194+
/**
195+
* Maps a subject to a JSON variation for a given experiment and provides additional details about the
196+
* variation assigned and the reason for the assignment.
197+
*
198+
* @param flagKey feature flag identifier
199+
* @param defaultValue default value to return if the subject is not part of the experiment sample
200+
* @returns an object that includes the variation value along with additional metadata about the assignment
201+
*/
202+
public getJSONAssignmentDetails(flagKey: string, defaultValue: object): IAssignmentDetails<object> {
203+
return this.client.getJSONAssignmentDetails(
204+
flagKey,
205+
this.subjectKey,
206+
ensureNonContextualSubjectAttributes(this.subjectAttributes),
207+
defaultValue
208+
);
209+
}
210+
211+
public getBanditAction(
212+
flagKey: string,
213+
defaultValue: string,
214+
): Omit<IAssignmentDetails<string>, 'evaluationDetails'> {
215+
return this.client.getBanditAction(flagKey, this.subjectKey, this.subjectAttributes, this.banditActions?.[flagKey] ?? {}, defaultValue);
216+
}
217+
218+
219+
public getBanditActionDetails(
220+
flagKey: string,
221+
defaultValue: string,
222+
): IAssignmentDetails<string> {
223+
return this.client.getBanditActionDetails(flagKey, this.subjectKey, this.subjectAttributes, this.banditActions?.[flagKey] ?? {}, defaultValue);
224+
}
225+
226+
/**
227+
* Evaluates the supplied actions using the first bandit associated with `flagKey` and returns the best ranked action.
228+
*
229+
* This method should be considered **preview** and is subject to change as requirements mature.
230+
*
231+
* NOTE: This method does not do any logging or assignment computation and so calling this method will have
232+
* NO IMPACT on bandit and experiment training.
233+
*
234+
* Only use this method under certain circumstances (i.e. where the impact of the choice of bandit cannot be measured,
235+
* but you want to put the "best foot forward", for example, when being web-crawled).
236+
*/
237+
public getBestAction(
238+
flagKey: string,
239+
defaultAction: string,
240+
): string {
241+
return this.client.getBestAction(flagKey, this.subjectAttributes, this.banditActions?.[flagKey] ?? {}, defaultAction);
242+
}
243+
244+
/**
245+
* For use with 3rd party CMS tooling, such as the Contentful Eppo plugin.
246+
*
247+
* CMS plugins that integrate with Eppo will follow a common format for
248+
* creating a feature flag. The flag created by the CMS plugin will have
249+
* variations with values 'control', 'treatment-1', 'treatment-2', etc.
250+
* This function allows users to easily return the CMS container entry
251+
* for the assigned variation.
252+
*
253+
* @param flagExperiment the flag key, control container entry and treatment container entries.
254+
* @returns The container entry associated with the experiment.
255+
*/
256+
public getExperimentContainerEntry<T>(flagExperiment: IContainerExperiment<T>): T {
257+
return this.client.getExperimentContainerEntry(
258+
flagExperiment,
259+
this.subjectKey,
260+
ensureNonContextualSubjectAttributes(this.subjectAttributes)
261+
);
262+
}
263+
264+
/**
265+
* Computes and returns assignments and bandits for the configured subject from all loaded flags.
266+
*
267+
* @returns A JSON string containing the precomputed configuration
268+
*/
269+
public getPrecomputedConfiguration(): Configuration {
270+
return this.client.getPrecomputedConfiguration(
271+
this.subjectKey,
272+
this.subjectAttributes,
273+
this.banditActions || {},
274+
);
275+
}
276+
277+
/**
278+
* Waits for the client to finish initialization sequence and be ready to serve assignments.
279+
*
280+
* @returns A promise that resolves when the client is initialized.
281+
*/
282+
public waitForInitialization(): Promise<void> {
283+
return this.client.waitForInitialization();
284+
}
285+
}

0 commit comments

Comments
 (0)