-
Notifications
You must be signed in to change notification settings - Fork 1
refactor: JS SDK v5 #256
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
base: main
Are you sure you want to change the base?
refactor: JS SDK v5 #256
Conversation
This integrates better with editor tools.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Love seeing more code deleted than added, yet new features developing from it.
@@ -0,0 +1,5 @@ | |||
{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹
} | ||
|
||
/** @internal */ | ||
type ConfigurationWire = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
heads up, IConfigurationWire is already defined here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. I tried using it but ran into it having readonly
fields, so I couldn't construct it like this:
const wire: ConfigurationWire = {
version: 1,
};
if (this.flags) {
wire.config = {
...this.flags,
response: JSON.stringify(this.flags.response),
};
}
// ...
It's also quite a bit more convoluted with braided strings and class implementation, etc.
So my idea is to have the simplest definition here, just for sanity check—it's not exported and is not used for anything but Configuration.toString()
/Configuration.fromString()
. (The design idea is that it's just a string representation of Configuration
.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe call it ConfigurationWireStrings
then? 😅
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why delete?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess the BaseEppoClient would actually provide this functionality.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, ConfigurationRequestor
is basically doing the same thing now with the new fetchConfiguration()
method (when paired with Configuration.toString()
)
|
||
/** @internal | ||
* | ||
* Returns flag configuration for the given flag key. Obfuscation is |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💪
@@ -14,7 +15,7 @@ import { | |||
Allocation, | |||
Split, | |||
VariationType, | |||
ConfigDetails, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎊
); | ||
const configFormat = flagsConfig?.response.format; | ||
const obfuscated = configFormat !== FormatEnum.SERVER; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should isObfuscated
be a method on Configuration
so we're not leaking the FormatEnum details?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isObfuscated()
is oversimplification in a sense that Configuration
contains multiple pieces (flags, bandits, precomputed), where each could be obfuscated, not obfuscated, or simply missing. So a single isObfuscated()
method doesn't cut it.
It also spreads this oversimplified model in programmers' brains, so we're more likely to miss some edge cases. I would rather have us working with raw data instead of half-baked abstractions. It may not be as pretty (initially) but at least it's obvious what's going on
re leaking details: my philosophy is that SDKs are in the know. Evaluation is highly dependent on flags response type, so there's no sense hiding it. When talking about leaking details, the only meaningful distinction for me is user–SDK. Configuration
is exposing full server responses but is marking them @internal
for this reason
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
re FormatEnum
specifically: it's currently mixing formats from two independent and unrelated configuration pieces (flags and precomputed), that actually have different rules for determining obfuscation
Next refactoring step is splitting it into FlagsConfigurationFormat
and PrecomputedConfigurationFormat
. And then we can have a proper isObfuscated(FlagsConfigurationFormat): boolean
helper to encode this piece of knowledge
Longer-term still, we shall add isObfuscated
field to flags configuration
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we need to handle differently formatted flags and bandits and whatnot. If bandit format doesn't match flags we could just throw an error or something.
return undefined; | ||
} | ||
return { | ||
response, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
great steps towards passing and checking the eTag in this SDK
obfuscated: | ||
this.getConfiguration()?.getFlagsConfiguration()?.response.format === FormatEnum.CLIENT, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
obfuscated computation should move somewhere centralized
src/broadcast.ts
Outdated
} | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: newline
/** | ||
* Enumeration of possible configuration sources. | ||
*/ | ||
export enum ConfigurationSource { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about initial/offline/bootstrap?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is considered as "cache" but I think it makes sense to add "User" type as well
@@ -21,6 +21,8 @@ import { Environment } from '../interfaces'; | |||
* | |||
* The policy choices surrounding the use of one or more underlying storages are | |||
* implementation specific and handled upstream. | |||
* | |||
* @deprecated To be replaced with ConfigurationStore and PersistentStorage. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are we not able to remove these entirely?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We'll be able to once I finish precomputed client migration
|
||
/** | ||
* ConfigurationCache is a helper class that subscribes to a configuration feed and stores latest | ||
* configuration in persistent storage. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
also loads from storage and publishes to the feed
private readonly storage: PersistentConfigurationStorage, | ||
private readonly configurationFeed?: ConfigurationFeed, | ||
) { | ||
configurationFeed?.addListener(async (configuration, source) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do we need to worry about cancelling the listener?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In what situation would you cancel the listener?
* has proper versions for all bandits references in flags | ||
* configuration. | ||
*/ | ||
const banditsUpToDate = (flags: FlagsConfig, bandits: BanditsConfig | undefined): boolean => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💯 I like the inversion here over what we've coded this as (needToFetchBandits). This makes it much more clear.
* The method is allowed to do async work (which is not awaited) or | ||
* throw exceptions (which are ignored). | ||
*/ | ||
storeConfiguration(configuration: Configuration | null): PromiseLike<void>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I haven't finished reading all the code yet but something to call out here if it's not already integrated, is the need for the persistent store to be keyed by API key (or some other function thereof) so that a single storage subsystem can store config for multiple api keys simultaneously.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that's best handled by individual constructors. We don't allow changing sdk key on the fly, so a persistent store instance is always used with the same key
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does the upstream creation have a way to not have these configurations clobber each other if different keys are used?
"./internal": { | ||
"types": "./dist/internal.d.ts", | ||
"default": "./dist/internal.js" |
There was a problem hiding this comment.
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.
* If the client is configured to precompute, returns a Subject-scoped instance for the | ||
* precomputed configuration. | ||
*/ | ||
public getPrecomputedSubject(): Subject | undefined { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This API is replacing a dedicated PrecomputedClient
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It still feels like a "client" to me as it will be doing assignment logging and other stuff beyond the scope of a "subject"
Perhaps we should call these things something like getClientForSubject()
or getSubjectClient()
, getPrecomputedSubjectClient()
, etc.,
const precomputed = config.getPrecomputedConfiguration(); | ||
if (precomputed && precomputed.subjectKey === subjectKey) { | ||
// Short-circuit evaluation if we have a matching precomputed configuration. | ||
const precomputedResult = this.evaluatePrecomputedAssignment( | ||
precomputed, | ||
flagKey, | ||
expectedVariationType, | ||
); | ||
|
||
return precomputedResult.flagEvaluation; | ||
} | ||
|
||
const flag = config.getFlag(flagKey); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
EppoClient now automatically handles precomputed assignments if precomputed configuration is present for the specified subject key
/** | ||
* A wrapper around EppoClient that automatically supplies subject key, attributes, and bandit | ||
* actions for all assignment and bandit methods. | ||
* | ||
* This is useful when you always want to use the same subject and attributes for all flag | ||
* evaluations. | ||
*/ | ||
export class Subject { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is subject-aware client
Initially named it subject-bound client but then was debating with myself whether I should duplicate ALL public API from client or just evaluation methods. We can always expose more methods later, so decided to only implement evaluation ones. Then realized that we can name it "Subject" and have getPrecomputedSubject()
on EppoClient, so that seemed like a neat idea
} | ||
|
||
public getConfiguration(): IConfiguration { | ||
return this.configuration; | ||
public async fetchConfiguration(): Promise<Configuration> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fetchConfiguration()
now returns full Configuration
object
ConfigurationRequestor is now also able to handle both regular and precomputed requests
* | ||
* @public | ||
*/ | ||
export class Configuration { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the boss
export interface FlagEvaluation { | ||
assignmentDetails: AssignmentResult; | ||
assignmentEvent?: IAssignmentEvent; | ||
banditEvent?: IBanditEvent; | ||
} | ||
|
||
export class Evaluator { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TODO for the future (non-breaking): evaluation is currently spread thin between EppoClient and Evaluator. It would make a ton of sense for Evaluator accept configuration storage as input and handle all evaluation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are you talking about the checks done in getAssignmentDetail()
? Agree that could be brought out of the client.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. Checks, handling of overrides, disabled flags, etc.
/** | ||
* Simple key-value store interface. JS client SDK has its own implementation based on localStorage. | ||
*/ | ||
export interface KVStore<T> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is old IAsyncStore
— renamed and removed isInitialized()
as it was unused. It is only used for overrides store now
import { ContextAttributes, FlagKey, HashedFlagKey } from './types'; | ||
|
||
// Base interface for all configuration responses | ||
interface IBasePrecomputedConfigurationResponse { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
no changes to types here — just moved from configuration-wire-types
* | ||
* @internal | ||
*/ | ||
export function generateSalt() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
actually found that SDK was not generating salt and getPrecomputedConfiguration was asking user to pass salt (which is a bad idea as I guess it was never passed)
@@ -0,0 +1,70 @@ | |||
# Configuration Lifecycle | |||
|
|||
This document explains how configuration is managed throughout its lifecycle in the Eppo SDK. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔥
|
||
1. **Configuration Loading Strategy**: | ||
- `stale-while-revalidate`: Uses cached config if within `maxStaleSeconds`, while fetching fresh data | ||
- `only-if-cached`: Uses cached config without network requests |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
During initialization, the client: | ||
|
||
1. **Configuration Loading Strategy**: | ||
- `stale-while-revalidate`: Uses cached config if within `maxStaleSeconds`, while fetching fresh data |
There was a problem hiding this comment.
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"
4. **Completion**: | ||
- Initialization completes when either: | ||
- Fresh configuration is fetched (for network strategies) | ||
- Cache is loaded (for cache-only strategies) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
* | ||
* @internal | ||
*/ | ||
export class BroadcastChannel<T extends unknown[]> { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
makes sense!
if (banditKey) { | ||
return this.parts.bandits?.response.bandits[banditKey] ?? null; | ||
} | ||
return null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could combine to simply
return banditKey && this.parts.bandits?.response.bandits[banditKey] ?? null;
} | ||
|
||
/** @internal */ | ||
type ConfigurationWire = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe call it ConfigurationWireStrings
then? 😅
export interface FlagEvaluation { | ||
assignmentDetails: AssignmentResult; | ||
assignmentEvent?: IAssignmentEvent; | ||
banditEvent?: IBanditEvent; | ||
} | ||
|
||
export class Evaluator { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are you talking about the checks done in getAssignmentDetail()
? Agree that could be brought out of the client.
); | ||
const configFormat = flagsConfig?.response.format; | ||
const obfuscated = configFormat !== FormatEnum.SERVER; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we need to handle differently formatted flags and bandits and whatnot. If bandit format doesn't match flags we could just throw an error or something.
* The method is allowed to do async work (which is not awaited) or | ||
* throw exceptions (which are ignored). | ||
*/ | ||
storeConfiguration(configuration: Configuration | null): PromiseLike<void>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does the upstream creation have a way to not have these configurations clobber each other if different keys are used?
## Communication Flow | ||
|
||
The ConfigurationFeed acts as a central hub through which different components communicate: | ||
|
||
 | ||
|
||
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. |
There was a problem hiding this comment.
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.
See documentation for a bit more context.
TODO: